☰
Unity动作系统实战:受伤与死亡状态机设计与实现
2026/10/20 4:49:25 网站建设 项目流程

1. 动作系统里最容易被忽视的一环:受伤与死亡

做动作游戏的人都有一个共识:把角色跑起来、跳起来、打出连招,这些都不算难,真正难的是让角色“合理地倒下”。我见过太多项目,攻击判定做得花里胡哨,受击反馈却只有一句Debug.Log("Hit"),角色挨打之后要么纹丝不动,要么直接原地消失。玩家嘴上说不出来哪里不对,但手感就是“飘”,打击感就是“软”。

这次要聊的案例,就是围绕Unity 动作系统中的人物受伤和死亡展开。它解决的核心问题很具体:角色在受到伤害时如何做出正确的反应,在生命值归零时如何优雅地退场,以及这两套逻辑如何和已有的状态机、动画系统、碰撞检测无缝衔接。适合已经能跑通基础移动和攻击、正准备补齐战斗闭环的开发者,也适合想系统梳理状态机设计思路的中级同学。

我个人的判断是,受伤和死亡这两个模块,是检验一个动作系统架构是否合格的试金石。因为它们同时牵扯到动画状态切换、输入锁定、碰撞体管理、事件广播、对象生命周期这五件事,任何一处设计得草率,后面都会以各种诡异 bug 的形式找上门来。下面我按实际项目里踩过的顺序,把这块内容完整拆一遍。

2. 整体设计思路:为什么受伤和死亡要分开处理

2.1 受伤和死亡的本质区别

很多人一开始会把死亡当成受伤的一种极端情况,觉得“血量为 0 就是受伤到死”,于是写一个TakeDamage函数里塞个 if 判断就完事。这个思路在小 demo 里能跑,但一旦项目变大就会崩。

原因在于,受伤是“可恢复的中间状态”,死亡是“不可逆的终态”。这两个状态在行为约束上完全不同:

  • 受伤状态下,角色仍然属于“活着的实体”,需要保留碰撞体、需要能被继续攻击、需要在硬直结束后回到正常状态。
  • 死亡状态下,角色要退出战斗逻辑,通常需要关闭受击判定、停止 AI 决策、播放死亡动画、延迟销毁或转为尸体。

我试过把它们混在一个状态里,结果就是角色死亡后还能被鞭尸触发受击动画,或者死亡动画播到一半被新的受击打断,画面非常滑稽。所以从架构上,我建议受击状态和死亡状态是两个独立的节点,共享一套伤害数据入口,但走不同的状态转移路径。

2.2 状态机中的位置安排

在一个典型的动作状态机里,状态大致可以分成几类:待机、移动、攻击、受击、死亡、特殊(翻滚、格挡等)。受伤和死亡应该挂在“受击”这一支下面,但优先级最高。

这里有个关键设计点:状态优先级。当角色正在播放攻击动画时被打中,应该立刻中断攻击进入受击;当角色正在受击硬直中再次被打中,是刷新硬直还是叠加,取决于你的设计。而死亡状态的优先级高于一切,任何状态收到“致死伤害”都必须无条件转入死亡。

我一般会用一个枚举来定义优先级:

public enum StatePriority { Normal = 0, // 待机、移动 Action = 1, // 攻击、技能 Hurt = 2, // 受击 Dead = 3 // 死亡,最高 }

状态切换时先比较优先级,只有新状态优先级大于等于当前状态才允许切换。这一条规则能挡掉大量“动画打架”的问题。

2.3 数据驱动的伤害入口

受伤和死亡都源于同一个事件:受到伤害。所以入口应该统一,我习惯定义一个DamageInfo结构体,把伤害相关的信息打包传递:

public struct DamageInfo { public float amount; // 伤害数值 public Vector3 hitPoint; // 命中点,用于特效定位 public Vector3 hitDirection;// 击退方向 public GameObject attacker;// 攻击来源 public bool isCritical; // 是否暴击 }

这样做的好处是,受击逻辑和死亡逻辑都只依赖这一个结构体,攻击方不需要知道对方会怎么反应,只管把DamageInfo丢过去。解耦是这块设计的第一原则,否则攻击代码里会写满“如果对方血少就播放死亡动画”这种越界判断,后期维护是灾难。

3. 受伤模块的核心细节与实操要点

3.1 受击动画的选择与方向判定

受伤动画通常不止一个。正面被打、背后被打、左侧被打、右侧被打,理想情况下应该有四套受击动画。但实际项目里,为了控制美术成本,很多团队只做正面和背面两套,或者干脆一套通用受击。

我的建议是:至少做正面和背面两套,因为玩家对“背后偷袭”的反馈期待和正面挨打是完全不同的。方向判定可以用攻击者与受击者的相对位置来计算:

Vector3 toAttacker = (attacker.transform.position - transform.position).normalized; float dot = Vector3.Dot(transform.forward, toAttacker); // dot > 0 表示攻击来自前方,dot < 0 表示来自后方

这里有个坑:不要用Vector3.Angle去算角度再判断,因为角度计算涉及反三角函数,在每帧多次受击的场景下开销不划算,而且Dot的符号判断已经足够区分前后。如果要做左右,再补一个Vector3.Dot(transform.right, toAttacker)即可。

注意:方向判定要在受击瞬间锁定,不能在受击动画播放过程中实时计算。因为角色可能在硬直中被击退转向,实时计算会导致动画中途切换,看起来像抽搐。

3.2 硬直时间的设定与取消窗口

受击硬直时间是打击感的核心参数。太短,玩家感觉不到被打;太长,玩家会觉得操作被剥夺,产生挫败感。

我的经验值是这样的:轻击受击硬直 0.2 到 0.3 秒,重击受击硬直 0.5 到 0.8 秒。这个时间要和受击动画的长度匹配,通常受击动画会有一个“恢复段”,硬直时间应该卡在恢复段开始的位置,这样动画能自然过渡回待机。

硬直的实现方式有两种,我强烈推荐用动画事件驱动而不是纯计时器:

// 在受击动画的恢复帧上挂 Animation Event public void OnHurtRecover() { if (currentHealth > 0) stateMachine.SwitchState(State.Idle); }

用计时器的坏处是,一旦动画速度被调整(比如加了受击减速效果),计时器和动画就对不上了。动画事件是跟着动画走的,天然同步。

另外要留一个受击取消窗口:在硬直的最后 20% 时间里,允许玩家输入翻滚或格挡来取消剩余硬直。这是提升操作上限的关键设计,很多动作游戏的高手感就来自这里。

3.3 击退与位移的处理

受击时的击退效果,我见过两种做法:一种是直接改 Transform 位置,一种是走物理系统加力。两种各有适用场景。

如果角色用的是CharacterController,那击退只能靠代码位移,因为 CharacterController 不吃物理力。这时候要注意:击退位移要逐帧衰减,不能一次性瞬移,否则看起来像闪现。

// 击退速度逐帧衰减 knockbackVelocity = Vector3.Lerp(knockbackVelocity, Vector3.zero, Time.deltaTime * 10f); characterController.Move(knockbackVelocity * Time.deltaTime);

如果角色用的是Rigidbody,那可以直接AddForce,但要记得在受击期间把drag调高,否则角色会滑出去很远。我一般会在进入受击状态时把drag从 0 调到 5 到 8,退出时再调回来。

提示:击退方向不要直接用攻击者的朝向,而应该用“从攻击者指向受击者”的水平方向。这样即使攻击者背对受击者,击退方向也是合理的。

3.4 受击特效与音效的挂载点

受击反馈里,视觉和听觉的贡献至少占一半。特效的生成位置应该用DamageInfo.hitPoint,而不是受击者的中心点,因为命中点才是玩家眼睛关注的地方。

音效这块有个细节:受击音效要做随机音调偏移。同一个音效连续播放会让人耳朵疲劳,我一般会在播放时随机调整pitch在 0.9 到 1.1 之间:

audioSource.pitch = Random.Range(0.9f, 1.1f); audioSource.PlayOneShot(hurtClip);

这个技巧成本极低,但反馈质量的提升非常明显,强烈建议加上。

4. 死亡模块的实现与生命周期管理

4.1 死亡状态的进入条件与不可逆性

死亡状态的进入条件只有一个:当前生命值减去伤害值后小于等于 0。这个判断要在受击逻辑的最前面做,一旦判定致死,直接跳过受击状态,进入死亡状态。

public void TakeDamage(DamageInfo info) { if (isDead) return; // 已死亡,忽略后续伤害 currentHealth -= info.amount; if (currentHealth <= 0) { EnterDeathState(info); } else { EnterHurtState(info); } }

注意那个if (isDead) return;,这是防止“鞭尸”的关键。很多项目忘了加这一句,结果角色死亡动画播放期间又被判定命中,触发了受击逻辑,死亡动画被打断,角色站起来又倒下,非常出戏。

4.2 死亡动画与尸体处理

死亡动画通常分两种:即时死亡(一击毙命,直接倒地)和濒死死亡(先跪地再倒下)。如果项目资源有限,一套倒地动画也能用,但要在动画前加一个短暂的“僵直”停顿,让死亡有重量感。

死亡动画播放完毕后,角色不应该立刻销毁,因为:

  • 玩家可能还想看到尸体,立刻消失会显得突兀。
  • 某些游戏机制需要尸体作为场景元素(比如遮挡视线、作为掩体)。

我的做法是:死亡动画结束后,把角色转为“尸体状态”,关闭所有逻辑组件,延迟 3 到 5 秒后淡出销毁。

IEnumerator DeathRoutine() { animator.Play("Death"); yield return new WaitForSeconds(deathAnimLength); // 关闭逻辑 enabled = false; collider.enabled = false; agent.enabled = false; // 延迟淡出 yield return new WaitForSeconds(3f); StartCoroutine(FadeOutAndDestroy()); }

淡出可以用材质透明度做,也可以用Destroy前的缩放动画。我偏好材质淡出,因为更自然。

4.3 死亡时的碰撞体与 AI 处理

死亡瞬间必须处理的三件事:关闭受击碰撞体、停止 AI 决策、从战斗管理器中注销。

关闭受击碰撞体是为了防止死亡后还被判定命中。但要注意,不要关闭整个 GameObject 的碰撞体,因为尸体可能还需要和地面、其他角色产生物理交互。正确的做法是把受击判定层和物理碰撞层分开,死亡时只关闭受击层。

AI 处理这块,如果角色用的是行为树或状态机,死亡时要主动Abort当前行为,否则 AI 可能还在尝试寻路到玩家身边,导致尸体在地上滑动。

从战斗管理器注销也很重要。很多项目有一个CombatManager维护所有活着的敌人列表,死亡时不注销的话,管理器会一直持有已销毁对象的引用,轻则报空引用,重则内存泄漏。

4.4 死亡后的对象池回收

如果项目用了对象池管理敌人,死亡后应该把对象归还池子而不是销毁。归还前要重置所有状态:血量回满、动画重置、碰撞体恢复、位置归零。

public void ResetForPool() { currentHealth = maxHealth; isDead = false; animator.Rebind(); animator.Update(0f); collider.enabled = true; transform.position = Vector3.zero; transform.rotation = Quaternion.identity; }

animator.Rebind()和animator.Update(0f)这两句是重置动画状态的关键,少了它们,对象从池子里取出来时会保留上次死亡时的动画姿势,非常诡异。

5. 完整实操流程:从受击到死亡的代码落地

5.1 状态机基类与状态注册

先把状态机的骨架搭好。我用的是一个轻量级的枚举驱动状态机,不依赖任何第三方插件:

public abstract class CharacterState { protected CharacterController controller; protected Animator animator; public virtual void Enter() { } public virtual void Update() { } public virtual void Exit() { } public virtual StatePriority Priority => StatePriority.Normal; }

每个具体状态继承这个基类,受击状态和死亡状态各自实现自己的Enter和Update。状态机本体负责优先级比较和切换:

public void SwitchState(CharacterState newState) { if (newState.Priority < currentState.Priority) return; currentState.Exit(); currentState = newState; currentState.Enter(); }

5.2 受击状态的完整实现

受击状态的Enter里要做四件事:播放动画、锁定输入、应用击退、生成特效。

public class HurtState : CharacterState { private float knockbackDecay = 10f; private Vector3 knockbackVelocity; public override StatePriority Priority => StatePriority.Hurt; public override void Enter() { // 1. 根据方向选择动画 string animName = DetermineHurtAnim(lastDamageInfo); animator.CrossFade(animName, 0.05f); // 2. 锁定输入 inputHandler.LockInput(true); // 3. 计算击退 knockbackVelocity = lastDamageInfo.hitDirection * knockbackForce; // 4. 生成特效 VFXManager.SpawnHitEffect(lastDamageInfo.hitPoint); } public override void Update() { knockbackVelocity = Vector3.Lerp(knockbackVelocity, Vector3.zero, Time.deltaTime * knockbackDecay); controller.Move(knockbackVelocity * Time.deltaTime); } public override void Exit() { inputHandler.LockInput(false); } }

CrossFade的过渡时间我设的是 0.05 秒,这个值很小,目的是让受击动画“瞬间切入”,强化打击感。如果设成 0.2 秒,受击会显得软绵绵的。

5.3 死亡状态的完整实现

死亡状态比受击状态简单,但生命周期管理更复杂:

public class DeathState : CharacterState { public override StatePriority Priority => StatePriority.Dead; public override void Enter() { // 1. 标记死亡 owner.isDead = true; // 2. 播放死亡动画 animator.CrossFade("Death", 0.1f); // 3. 关闭受击判定 owner.hurtCollider.enabled = false; // 4. 停止 AI owner.aiAgent.enabled = false; // 5. 从战斗管理器注销 CombatManager.Instance.Unregister(owner); // 6. 启动销毁协程 owner.StartCoroutine(DeathCleanupRoutine()); } private IEnumerator DeathCleanupRoutine() { yield return new WaitForSeconds(owner.deathAnimLength); yield return new WaitForSeconds(3f); ObjectPool.Instance.Return(owner.gameObject); } }

这里我把销毁逻辑放在协程里,而不是用Invoke,因为协程更容易在对象被提前回收时统一取消。

5.4 伤害传递的完整链路

把整条链路串起来看:攻击方的碰撞检测触发 → 构造DamageInfo→ 调用受击方的TakeDamage→ 受击方判断生死 → 切换状态 → 状态执行动画和位移 → 动画事件触发恢复或销毁。

// 攻击方 void OnHitDetected(Collider other) { var damageable = other.GetComponent<IDamageable>(); if (damageable != null) { DamageInfo info = new DamageInfo { amount = attackDamage, hitPoint = other.ClosestPoint(transform.position), hitDirection = (other.transform.position - transform.position).normalized, attacker = gameObject }; damageable.TakeDamage(info); } }

用接口IDamageable而不是具体类,是为了让场景中的可破坏物、NPC 都能复用同一套伤害入口。

6. 常见问题与排查技巧实录

6.1 受击动画不播放或播放错误

最常见的原因是Animator 的过渡条件没配好。如果受击动画是通过CrossFade直接播放的,要确保 Animator 里没有其他过渡在抢占控制权。我遇到过攻击动画的Has Exit Time没关,导致受击动画播到一半被攻击动画的退出过渡拉回去。

排查方法:在 Animator 窗口里观察状态流转,看受击状态是否被正确进入。如果发现进入后立刻被弹出,检查是否有Any State的过渡条件过于宽松。

6.2 死亡后角色还能被攻击

这个问题我在前面提过,根因是受击碰撞体没关闭,或者isDead标志位没在TakeDamage开头检查。还有一种隐蔽情况:多个碰撞体。角色身上可能同时有胶囊碰撞体(物理)和球形碰撞体(受击),死亡时只关了一个。

排查方法:在TakeDamage里打印isDead的值,如果死亡后还能打印出false,说明标志位被重置了,检查是否有其他地方在改这个值。

6.3 击退方向反了

击退方向反了通常是hitDirection的计算写反了。记住原则:从攻击者指向受击者。如果你写成了(attacker.position - self.position),那方向就是反的。

还有一种情况是角色模型本身朝向和 Transform 的 forward 不一致,导致视觉上击退方向偏了 90 度。这时候要检查模型的导入设置,确保 forward 轴正确。

6.4 死亡动画播完后角色悬空

这是动画根运动(Root Motion)的问题。如果死亡动画带根运动,而角色用的是 CharacterController,动画播完后角色可能停在半空。解决办法是死亡动画关闭根运动,改用代码控制倒地位置,或者在动画末尾用Animation Event把角色贴地。

6.5 常见问题速查表

问题现象可能原因排查方向
受击动画不播放Animator 过渡冲突检查 Any State 过渡条件
死亡后被鞭尸isDead 未检查或碰撞体未关检查 TakeDamage 开头和碰撞体状态
击退方向错误hitDirection 计算反向确认是攻击者指向受击者
死亡后悬空根运动未关闭检查动画根运动设置
尸体滑动AI 未停止检查 aiAgent.enabled
对象池取出后姿势错误动画未重置补上 animator.Rebind()

6.6 几个我踩过的坑

第一个坑:受击硬直期间允许移动。早期我没锁输入,结果角色一边播放受击动画一边还能跑,看起来像滑步。后来在受击状态里强制LockInput,问题解决。

第二个坑:死亡动画被受击动画覆盖。原因是死亡状态优先级虽然高,但受击状态的Enter是在TakeDamage里直接调用的,绕过了状态机的优先级检查。后来我把所有状态切换都收口到状态机的SwitchState方法,统一走优先级判断。

第三个坑:对象池回收后特效残留。受击特效是独立对象,角色回收时特效还在播。解决办法是把特效挂到角色子节点下,或者用特效管理器统一跟踪生命周期。

7. 一些参数调优的经验值

最后分享几组我实际项目里调出来的参数,可以直接作为起点,再根据手感微调。

受击硬直时间:轻击 0.25 秒,重击 0.6 秒。击退力度:轻击 2 到 3 米每秒,重击 5 到 8 米每秒。击退衰减系数:8 到 12,数值越大停得越快。受击动画过渡时间:0.05 秒。死亡动画过渡时间:0.1 秒。尸体停留时间:3 到 5 秒。

这些值不是绝对的,但它们是我在多个项目里反复验证过的“不会出错”的区间。新手可以直接抄,老手可以在此基础上按自己的节奏感调整。

我个人在实际操作中的体会是,受伤和死亡这块最忌讳的就是“先凑合,以后再优化”。因为它们是战斗系统的出口,所有攻击逻辑最终都汇聚到这里,一旦这里设计得混乱,后面每加一个新敌人、新武器,都要回来改一遍。花两天时间把状态机、伤害入口、生命周期这三件事理清楚,后面能省下两周的调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询