:從Animator狀態(tài)機到攻擊判定實戰(zhàn)指南)
簡介一套基于Unity引擎的2D街機游戲開發(fā)資源以經(jīng)典“三國戰(zhàn)紀”式玩法為線索系統(tǒng)梳理從場景搭建、Sprite精靈、Animator動畫、2D物理碰撞、輸入處理到C#游戲邏輯、音頻管理、UI界面、性能優(yōu)化與多平臺打包的完整技術鏈條。資源共443個文件壓縮包僅4.5MB其中包含90個C#腳本、30個PNG素材、24個Anim動畫文件、22個Asset以及Prefab、Shader、Controller等類型覆蓋代碼、美術、動畫、配置與場景數(shù)據(jù)便于對照學習或直接拆解復用。目前已有335人學習下載。適合Unity初學者或希望了解2D街機動作游戲開發(fā)流程的開發(fā)者通過實際資源快速掌握精靈動畫、狀態(tài)機、剛體碰撞及角色技能等關鍵環(huán)節(jié)同時可作為小型橫版闖關項目啟動模板節(jié)省從零搭建的重復勞動。1. 拿到一套三國戰(zhàn)紀風格的2D動畫資源先搭狀態(tài)機再談寫代碼把 Skill1.anim、Skill2.anim、Attack2.anim、Hurt3.anim、Idle.anim、FrontJump.anim、Run.anim、Walk.anim、RunAttack.anim 這一串動畫剪輯丟進 Unity 的時候很多人的第一反應是寫移動腳本。我的建議正好相反先搭 Animator 狀態(tài)機腳本放到第二步。因為這些 .anim 文件本身不會讓角色動起來它們只是關鍵幀數(shù)據(jù)真正驅(qū)動角色的是狀態(tài)機里那一條條轉換箭頭。這篇文章適合兩類人第一次在 Unity 里做 2D 街機動作游戲、對著資源不知道從哪下手的以及手上正好有序列幀動畫資源、想復現(xiàn)三國戰(zhàn)紀那種清版動作手感的人。這類橫版游戲的角色動畫訴求高度固定——待機、行走、跑動、輕攻擊、跑動攻擊、技能、跳躍、受擊這套資源基本都覆蓋了把它理順就是一次完整的 Unity 2D 游戲流程演練。2. Unity 2D街機游戲從哪里入手場景、精靈與Animator狀態(tài)機的搭法2.1 先搞清楚.anim剪輯引用的是誰精靈切片與資源導入很多人拿到 .anim 文件直接往場景里拖結果看到洋紅色方塊或者空白第一反應是資源壞了。其實 .anim 是 Unity 的動畫剪輯文件里面存的是關鍵幀、曲線和事件真正的像素來自 Sprite。檢查方法很簡單選中某個 .anim在 Inspector 里看它的關鍵幀引用了哪張貼圖。我一般按這個順序處理動畫資源把序列幀大圖導入項目在 Inspector 里把 Texture Type 設為 Sprite (2D and UI)Sprite Mode 設為 Multiple點擊 Apply。打開 Sprite Editor按 Grid by Cell Size 切片。切多大取決于動畫資源的分辨率比如單幀是 128x128就設 128 到 128。切完片后檢查每個 .anim 的 Sprite 引用是否指向切好的幀。如果引用的還是舊貼圖把對應幀拖進關鍵幀位置替換。再把 .anim 全部選中拖進場景里同一個 GameObject掛上 Animator 組件把 Animator Controller 指進來。這里最容易被忽略的是 Pivot 設置。人物類的切片我統(tǒng)一用 Bottom Center也就是腳底中心。為什么因為橫版游戲里角色的腳要踩在地面上Box Collider 2D 生成時也會自動貼合身體底部。如果你用默認的 Center后面碰撞器對位的時候十有八九要手動調(diào) offset調(diào)來調(diào)去角色就懸空了。2.2 搭Animator狀態(tài)機十個動畫按一套邏輯串起來這套動畫清單正好是一個標準的橫版清版動作角色的完整動作集待機 Idle、行走 Walk、跑動 Run、輕攻擊 Attack2、跑動攻擊 RunAttack、兩段技能 Skill1/Skill2、跳躍 FrontJump、受擊 Hurt3。我的狀態(tài)機結構大致是這樣一張轉換表當前狀態(tài)驅(qū)動條件目標狀態(tài)備注Any StateHurt3 TriggerHurt3受擊優(yōu)先級最高打斷一切動作Any StateFrontJump TriggerFrontJump跳躍同樣可以打斷普通攻擊IdleSpeed 0.1Walk帶 Has Exit Time 做平滑過渡WalkSpeed 0.5Run用閾值區(qū)分走和跑Run / Idle / WalkAttack TriggerAttack2攻擊不帶 Exit Time按下立刻切RunRunAttack TriggerRunAttack跑動攻擊播完回 RunIdle / 移動Skill1 / Skill2 TriggerSkill1 / Skill2技能是硬直狀態(tài)期間鎖移動Attack2 / Skill動畫播完Idle用 Exit Time 回到待機記住一條原則攻擊、跳躍、受擊這類需要“立刻響應”的轉換Has Exit Time 必須取消勾選只靠 Trigger 驅(qū)動而 Attack2 播完回 Idle 這個轉換要勾選 Has Exit Time否則動畫還沒播完就被強行切回去。Transition Duration 也要設為 0。默認的 0.25 秒過渡在移動狀態(tài)之間是手感潤滑劑但在攻擊動作上就是災難——Attack2 還沒打完就開始往 Idle 插值角色的刀會“飄”一段中間幀。2.3 用代碼驅(qū)動狀態(tài)切換Trigger與Bool的正確用法狀態(tài)機搭好后需要一個驅(qū)動腳本。我習慣把動畫驅(qū)動單獨寫一個腳本和角色控制分開using UnityEngine; public class PlayerAnimatorDriver : MonoBehaviour { private Animator anim; private void Awake() { // Animator組件必須存在取不到直接報錯避免后面空引用 anim GetComponentAnimator(); } private void Update() { // Horizontal是Input Manager里默認的軸GetAxisRaw沒有平滑值 float h Input.GetAxisRaw(Horizontal); // Speed是Animator窗口里的Float參數(shù)名字不能拼錯 anim.SetFloat(Speed, Mathf.Abs(h)); if (Input.GetKeyDown(KeyCode.J)) { // 先Reset再Set防止上一次Trigger沒被消費導致連按閃跳 anim.ResetTrigger(Attack); anim.SetTrigger(Attack); } if (Input.GetKeyDown(KeyCode.K)) { anim.ResetTrigger(Jump); anim.SetTrigger(Jump); } } }邏輯說明Speed 參數(shù)負責 Idle、Walk、Run 這組“移動類”狀態(tài)的切換Attack 和 Jump 用 Trigger 做一次性觸發(fā)。Trigger 的特點是用完自動歸位不會卡住狀態(tài)機但同一幀里如果連按兩次Trigger 會被合并成一次所以先 ResetTrigger 再 SetTrigger 是防抖的標準做法。參數(shù)命名有一點必須注意代碼里的參數(shù)名和 Animator 窗口里的參數(shù)名大小寫完全一致。Unity 不會因為你傳錯了 Trigger 名字報錯只是靜默失敗——這是最坑的地方我見過有人排查了一晚上最后發(fā)現(xiàn) Skill1 在動畫面板里寫的是 skill1。3. 輸入與角色控制三道C#腳本把街機按鍵映射到鍵盤3.1 街機按鍵布局的映射方案三國戰(zhàn)紀這類清版動作游戲標準配置是搖桿加三個攻擊鍵。搬到鍵盤上我的映射是街機按鍵功能鍵盤映射說明搖桿移動WASD 或 方向鍵響應要生不加平滑A 鍵攻擊J主攻擊放在左手食指位B 鍵跳躍K食指旁邊方便連按C 鍵技能L技能釋放頻率低放遠一點不誤觸D 鍵跑動攻擊U和 B 分開避免誤觸為什么不推薦用鼠標因為這類游戲的輸入本質(zhì)是“方向 動作”的組合比如跑動攻擊是“按住方向再按攻擊”。鍵盤和手柄天然支持多鍵同時按住鼠標做不到。另外手柄玩家不用改邏輯Unity 的舊輸入系統(tǒng)會自動把手柄軸映射到 Horizontal。3.2 用Input讀取移動與攻擊指令Update里收集FixedUpdate里消費輸入讀取我單獨放在一個腳本里不跟角色控制器混在一起。原因很簡單輸入要在 Update 里采集物理運動在 FixedUpdate 里執(zhí)行。如果直接在 FixedUpdate 里讀 Input遇到幀率波動會漏掉按鍵。using UnityEngine; public class PlayerInput : MonoBehaviour { public float Horizontal { get; private set; } public bool JumpPressedThisFrame { get; private set; } private void Update() { // GetAxisRaw返回-1到1沒有平滑濾波適合街機這種強調(diào)即時響應的場景 Horizontal Input.GetAxisRaw(Horizontal); // GetKeyDown只在按下的那一幀返回true適合做Trigger JumpPressedThisFrame Input.GetKeyDown(KeyCode.K); if (Input.GetKeyDown(KeyCode.J)) { OnAttackPressed?.Invoke(); } if (Input.GetKeyDown(KeyCode.L)) { OnSkillPressed?.Invoke(); } } public event System.Action OnAttackPressed; public event System.Action OnSkillPressed; }邏輯說明Horizontal 用屬性暴露出來角色控制器在 FixedUpdate 里拿這個值去改剛體速度。攻擊和技能用 C# 事件拋出去這樣輸入腳本不關心誰在聽狀態(tài)機腳本和攻擊腳本各自訂閱。JumpPressedThisFrame 是給角色控制器判斷用的用一次清一次。這里我特意用 Input.GetAxisRaw 而不是 Input.GetAxis兩者的區(qū)別是平滑濾波。GetAxis 自帶一段斜坡加速在橫版動作里會讓角色“飄”起步和停步都不干脆。街機手感要的是按下去立刻走、松開立刻停Raw 是正確選擇。3.3 移動、攻擊、跳躍的腳本協(xié)同攻擊時鎖定移動角色控制器是這套代碼的核心。它的職責有三塊移動、朝向、配合動畫狀態(tài)機。using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] public class CharacterController2D : MonoBehaviour { public float moveSpeed 6f; public float jumpForce 8f; private Rigidbody2D rb; private Animator anim; private bool isAttacking false; private void Awake() { rb GetComponentRigidbody2D(); anim GetComponentAnimator(); } private void FixedUpdate() { // 攻擊和技能釋放期間鎖死移動這是動作游戲手感的基本盤 if (isAttacking) return; float h GetComponentPlayerInput().Horizontal; rb.velocity new Vector2(h * moveSpeed, rb.velocity.y); // 用Scale.x正負控制朝向注意子物體判定框也要跟著翻 if (h ! 0f) { Vector3 scale transform.localScale; scale.x Mathf.Sign(h) * Mathf.Abs(scale.x); transform.localScale scale; } anim.SetFloat(Speed, Mathf.Abs(h)); } public void SetAttacking(bool value) { isAttacking value; } }邏輯說明移動統(tǒng)一用 Rigidbody2D.velocity 賦值不是 transform.Translate。因為玩家是 Dynamic 剛體物理引擎按照速度積分算位置你在 Update 里直接改 transform下一物理幀會被剛體速度覆蓋回去表現(xiàn)出來就是抖動。moveSpeed 6 是 2D 橫版角色的常見初值配合 Orthographic 相機的 Size 來感受。如果屏幕里角色跑得太慢先別急著改代碼檢查 Camera 的 Orthographic Size 是不是默認 5這個值決定了一個屏幕里能看到多少世界單位。一般來說1920x1080 的分辨率下 Orthographic Size 設為 8角色按 6 的速度跑動節(jié)奏比較合適。攻擊鎖定這步最容易被新手漏掉。我在攻擊腳本里這樣接線public void LaunchAttack() { SetAttacking(true); anim.ResetTrigger(Attack); anim.SetTrigger(Attack); }SetAttacking(true) 之后FixedUpdate 里的移動代碼直接 return角色出招過程中推搖桿也不會走位。攻擊動畫播完由 Animation Event 或者協(xié)程回調(diào) SetAttacking(false)。這個標志位如果沒做就會出現(xiàn)動畫在攻擊、角色還在地上滑行的違和感。4. 2D碰撞與攻擊判定碰撞器參數(shù)、Animation Event與LayerMask實戰(zhàn)4.1 Rigidbody2D與Collider2D的參數(shù)選擇橫版動作里物理組件的參數(shù)直接決定手感我把推薦值整理成一張表組件參數(shù)推薦值原因Rigidbody2DBody TypeDynamic玩家角色由物理驅(qū)動靜態(tài)剛體留給地面Rigidbody2DCollision DetectionDiscrete常規(guī)移速足夠用高速沖刺再開 ContinuousRigidbody2DInterpolateInterpolated防止低幀率下手感發(fā)頓Rigidbody2DConstraints凍結 Z 旋轉角色倒地亂轉基本是沒凍結旋轉BoxCollider2D大小貼合身體不含武器判定按身體算武器是攻擊判定框的事攻擊判定框isTrigger開啟觸發(fā)器不參與物理碰撞只做傷害檢測Collision Detection 這個參數(shù)值得多說一句。Discrete 是離散檢測每物理幀檢查一次角色速度不高時完全夠用。但如果你把 moveSpeed 調(diào)成 20 以上角色就會在幀間穿過薄墻——這時候才需要把檢測模式改成 Continuous。注意 Continuous 是有性能代價的只用在高危對象上不要全場景無腦開。角色腳下還有一個必須處理的點落地檢測。兩次跳躍之間的間隔靠什么判斷常見做法是腳底放一個很小的空物體掛 BoxCollider2D只參與地面層碰撞然后在地面物體的 OnCollisionEnter2D 回調(diào)里置 isGrounded 為 true。這個做法比在 Update 里發(fā)射射線穩(wěn)定射線很容易漏掉高速下落的那一幀。4.2 用Animation Event在攻擊幀觸發(fā)傷害判定攻擊判定我不用 Update 輪詢而是用 Animation Event。這樣刀揮到哪一幀觸發(fā)傷害直接在動畫時間軸上看百分百直觀。選中 Attack2.anim 文件在 Inspector 底部的事件軌上點 Add Animation Event把事件拖到攻擊幀——也就是動作幅度最大、刀伸到最前面的那一幀附近方法名填 HitCheck。public class AttackHitBox : MonoBehaviour { public GameObject hitBoxObject; // 攻擊判定子物體默認隱藏 public float activeTime 0.15f; // 判定框存活時間單位秒 public void HitCheck() { // 先激活判定框注意碰撞響應發(fā)生在下一個物理幀 hitBoxObject.SetActive(true); StopAllCoroutines(); StartCoroutine(HideAfterDelay()); } private System.Collections.IEnumerator HideAfterDelay() { yield return new WaitForSeconds(activeTime); hitBoxObject.SetActive(false); } }邏輯說明HitCheck 里激活判定框用協(xié)程延遲關閉確保判定框只在攻擊幀那一段時間內(nèi)存在。activeTime 0.15 秒大約是 9 幀覆蓋一次普通攻擊的傷害窗口足夠了。技能 Skill1/Skill2 如果有多段傷害就在動畫里多掛幾個事件每段各激活一次。這里的坑在于 SetActive(true) 之后OnTriggerEnter2D 不會在當前幀立刻觸發(fā)。Unity 的物理系統(tǒng)是獨立步進的碰撞回調(diào)要到下一個物理幀才會派發(fā)。所以判定框要提前幾幀激活不能把事件掛在攻擊動畫的最后一幀否則傷害會吞掉。我一般把事件掛在攻擊幀的前 1-2 幀。4.3 LayerMask與OnTriggerEnter2D的實戰(zhàn)配置傷害檢測的代碼要用 LayerMask 做過濾而不是在 OnTriggerEnter2D 里判斷 tag 字符串。字符串比較容易寫錯字而且性能比位運算差。public class PlayerAttackHitBox : MonoBehaviour { public LayerMask enemyLayer; public int attackDamage 10; private void OnTriggerEnter2D(Collider2D other) { // LayerMask位運算把other所在層轉成bit再和enemyLayer按位與 if (((1 other.gameObject.layer) enemyLayer.value) 0) { return; // 不是敵人層直接忽略 } EnemyHealth enemy other.GetComponentEnemyHealth(); if (enemy ! null) { enemy.TakeDamage(attackDamage); } } }邏輯說明1 other.gameObject.layer是把層索引轉成對應的二進制位比如層 6 轉出來是 1 左移 6 位等于 64。enemyLayer.value 是在 Inspector 里勾選層的組合值。兩者按位與之后為 0說明這個碰撞體不在敵人層上直接返回。這個寫法比other.CompareTag(Enemy)快也更安全——Inspector 里勾選層不會拼錯字符串。LayerMask 和 RenderingLayerMask 是兩套東西新手經(jīng)常在 Inspector 里找錯字段。LayerMask 是物理用的負責碰撞矩陣、射線檢測、傷害過濾RenderingLayerMask 是渲染管線用的負責遮擋剔除和光照分組。攻擊判定里用的是物理 LayerMask不要被名字里帶 Layer 的字段帶偏。最后檢查一處Physics2D 的碰撞矩陣。在 Edit - Project Settings - Physics2D 里玩家層和敵人層要勾上碰撞攻擊判定框?qū)雍蛿橙藢右瓷?Trigger 響應。矩陣默認是全勾的但項目里層多之后很容易出現(xiàn)玩家攻擊框和道具層誤開物理響應導致攻擊判定被道具擋掉。5. 避坑橫版動作手感復現(xiàn)里最常見的五個坑5.1 受擊動畫Hurt3根本播不出來現(xiàn)象角色被打時沒有反應技能動畫播到一半完全無視 Hurt3或者 Hurt3 播了一幀就被彈回 Idle。原因Animator 里受擊轉換掛在 Any State 上但它的優(yōu)先級低于當前狀態(tài)的內(nèi)部轉換。技能 Skill1 的動畫轉換到自身循環(huán)的箭頭排在前面Hurt3 的 Trigger 雖然接收到了但是被技能狀態(tài)內(nèi)部的優(yōu)先級壓住了。解決在 Animator 窗口的 Layer 面板里找到 Hurt 轉換箭頭把它拖到轉換列表的頂部。右鍵轉換條件確認 Can Transition To Self 處于關閉狀態(tài)。這樣無論角色在哪個狀態(tài)受擊 Trigger 都能搶到控制權。代碼側同時保證 ResetTrigger(Hurt3) 和 SetTrigger(Hurt3) 成對出現(xiàn)。5.2 攻擊時還能推搖桿跑動狀態(tài)機亂跳現(xiàn)象Attack2 動畫播放過程中角色還能在地上滑來滑去看起來像滑冰。原因動畫狀態(tài)機這邊攻擊轉換做對了但代碼側沒有鎖移動。FixedUpdate 里還在照常讀 Horizontal 并給剛體賦值速度玩家按住方向鍵角色就邊打邊跑。解決用 isAttacking 標志位在 LaunchAttack() 里 SetAttacking(true)攻擊動畫的結束事件里 SetAttacking(false)。FixedUpdate 開頭先查標志位為 true 直接 return。如果要更狠一點攻擊期間連二段跳都鎖保證出招全程是硬直。這套做法是從格斗游戲里抄來了角色在攻擊動作里就只有攻擊這一件事。5.3 角色貼地懸空碰撞器和精靈對不上現(xiàn)象角色看起來踩在地磚上但碰撞器明顯懸在半空或者碰撞器在地板下面導致角色不能正常下臺階。原因Sprite Pivot 用了默認的 Center。序列幀大圖切片之后人物在圖中的位置可能偏高或偏低Center 基準會讓碰撞器跟著人物貼圖的重心走而不是腳底。解決在 Sprite Editor 里把這一套動畫相關的切片 Pivot 全部改為 Bottom Center然后重新生成 Box Collider 2D。如果是已經(jīng)調(diào)好的項目可以手動改 BoxCollider2D 的 offset 和 size把底部對齊到角色腳底。以后導入任何人物類素材第一件事就是統(tǒng)一 Pivot 到腳底省掉后面所有的對位返工。5.4 連續(xù)攻擊的第一刀沒有傷害現(xiàn)象每次進入戰(zhàn)斗后的第一次攻擊打不到人第二刀、第三刀正常。原因攻擊判定框掛的是 SetActive 方案。第一次 HitCheck 激活判定框時角色和敵人之間的距離夠近但物理系統(tǒng)要到下一物理幀才派發(fā) OnTriggerEnter2D等事件派發(fā)時判定框已經(jīng)因為協(xié)程超時被關掉了。通俗說就是激活后的首幀碰撞還沒結算框就沒了。解決把 Animation Event 放在攻擊動作的前 1 到 2 幀給物理結算留出時間差。另一個方向是把判定框常駐用 LayerMask 過濾攻擊窗口攻擊期間才允許傷害觸發(fā)這樣就不依賴激活時機了。經(jīng)驗值是任何攻擊判定如果你發(fā)現(xiàn)連續(xù)攻擊第二次正常第一次打空先查事件幀和物理幀的時間差而不是查碰撞器。5.5 角色翻轉時攻擊判定框方向錯位現(xiàn)象角色朝左攻擊時判定框跑到了背后或者判定框的位置和手臂方向相反。原因翻轉用了localScale.x * -1但攻擊判定框的 localPosition 是正方向的值。Scale 翻轉只翻 Sprite 和掛接的子物體坐標而判定框如果設了對稱位置比如 x0.8翻轉后它跑到 x-0.8正好是背對敵人。解決翻轉不要用 Scale改用transform.rotation Quaternion.Euler(0, 180, 0)。這樣整個坐標系跟著角色鏡像localPosition 始終是正前方。如果項目里已經(jīng)全面使用了 Scale 翻轉那判定框位置要用代碼重新算attackBox.localPosition new Vector3(isFacingRight ? 0.8f : -0.8f, 0, 0)。我更推薦第二種因為只需要改一處不用把所有 Sprite 的翻轉習慣推翻。6. 進階用動畫Layer與調(diào)試技巧把戰(zhàn)紀手感調(diào)出來6.1 跑動攻擊的終極方案動畫分層RunAttack 這個動作在橫版街機里有個經(jīng)典需求角色邊跑邊出拳上半身揮拳下半身繼續(xù)邁腿。如果把它放在同一個動畫層里就只能在“跑”和“跑動攻擊”之間硬切動作容易斷。Animator Controller 支持多 Layer把 Attack2、Skill1、Skill2 放到 UpperBody 層Base Layer 只保留 Idle、Walk、Run、Hurt3、FrontJump。Layer 面板里給 UpperBody 設一個 Avatar Mask只留手臂和上半身的骨骼權重權重參數(shù)在代碼里用 SetLayerWeight 控制。街機手感推薦 0 和 1 之間不帶過渡直接從 0 切到 1 再切回 0響應最脆。這一層還能順手解決技能釋放中的受擊表現(xiàn)——Hurt3 放在 Base Layer 優(yōu)先級最高UpperBody 的動畫不會搶。6.2 不靠猜排錯用normalizedTime精確定位動畫幀調(diào)試狀態(tài)機的時候不要盯著 Scene 窗口看角色動作猜當前在哪一幀直接在代碼里打印private void CheckAnimState() { AnimatorStateInfo info anim.GetCurrentAnimatorStateInfo(0); // IsName判斷當前狀態(tài)normalizedTime超過1說明已經(jīng)開始退出 Debug.Log($當前動畫: {info.IsName(Attack2)}, 進度: {info.normalizedTime:F2}); }normalizedTime 是動畫播到第幾遍的歸一化進度0.35 就代表播放到第三成。我在調(diào)傷害幀的時候都是先用這段代碼跑一遍攻擊看事件該掛在哪個百分比再回動畫面板對準。比反復進 Play 模式試錯快得多。做這套東西做多了之后我有一個習慣每次搭角色動畫都強制在 Animator 窗口里逐條過一遍轉換箭頭把 Any State 的濫用改掉受擊權重拉到最頂傷害事件的時間戳先寫進策劃表再掛事件不靠試。狀態(tài)機這事亂拉箭頭一時爽調(diào)手感火葬場。希望幫到你。本文還有配套的精品資源點擊獲取