攻击系统的本质
攻击系统不只是“按下鼠标后播放 Attack 动画”。它实际协调了五套状态:
- 输入状态:这一帧有没有按下攻击键。
- 游戏逻辑状态:角色现在是否允许攻击、正在第几段、连击窗口是否过期。
- 物理状态:攻击时能否移动、跳跃、下落、冲刺和转向。
- Animator 状态:应该进入 Attack1、Attack2 还是 Attack3。
- 动画时间轴状态:什么时候产生攻击判定、什么时候允许接下一段、什么时候结束攻击。
当前代码的数据流是:
Animator 负责“表现哪个动作”,Player 脚本负责“游戏规则是否允许这个动作”。如果把攻击是否结束完全交给 Animator 参数自己推断,或者让多个方法同时修改 Rigidbody2D 速度,就容易出现状态互相覆盖。
父子对象与组件职责参见 02 角色对象层级与组件职责;Animator 参数和 Transition 通用属性参见 05 动画与混合树。
当前三段攻击的实际 Animator 结构
项目中的 Animator 当前使用三个独立状态:
目标状态 | Any State 进入条件 | 退出方式 |
PlayerAttack1 | isAttacking = true;isGrounded = true;comboCounter = 0 | Has Exit Time = true;Exit Time = 1;无额外条件;Duration = 0 |
PlayerAttack2 | isAttacking = true;isGrounded = true;comboCounter = 1 | Has Exit Time = true;Exit Time = 1;无额外条件;Duration = 0 |
PlayerAttack3 | isAttacking = true;isGrounded = true;comboCounter = 2 | Has Exit Time = true;Exit Time = 1;无额外条件;Duration = 0 |
三个 Any State → Attack Transition 的
Can Transition To Self 都已经关闭,这是正确的。否则 isAttacking 在整段攻击期间持续为 true,Animator 可能不断重新进入同一个 Attack 状态,使动画从第一帧反复开始。Attack → Idle 使用 Exit Time = 1,表示攻击动画播放完一轮后回到 Idle。Transition Duration = 0 适合逐帧 Sprite 动画,因为不同 Sprite 之间不存在真正的姿势插值。
当前三个 Attack Clip 的实际设置是:
Clip | 长度 | AttackOver 事件时间 | Loop Time |
PlayerAttack1 | 约 0.7 秒 | 0.6 秒 | 当前开启 |
PlayerAttack2 | 约 0.4 秒 | 0.3 秒 | 当前开启 |
PlayerAttack3 | 约 0.7 秒 | 0.6 秒 | 当前开启 |
AttackOver 放在结束前约 0.1 秒,可以让逻辑先释放攻击状态,再由 Exit Time 把画面切回 Idle。三个攻击都是一次性动作,原则上应该关闭 Loop Time。即使 Exit Time 正常时通常只播放一轮,开启循环仍会在退出失败、事件丢失或状态被重新进入时重复触发事件。
Animator 参数分别代表什么
isAttacking
isAttacking 是一个持续状态,不是一次按键事件:- true:角色正处于攻击生命周期中。
- false:攻击已结束或被取消。
- 它同时影响 Animator、移动锁定、冲刺许可和以后可能增加的伤害判定。
如果只需要告诉 Animator“刚刚按下了一次攻击”,Trigger 更接近一次性请求;但角色逻辑仍然需要一个类似
isAttacking 的变量表示攻击期间的持续状态。可以把“攻击请求”和“正在攻击”分成两个概念,避免一个 Bool 同时承担两种职责。comboCounter
comboCounter 表示下一次攻击应该选择第几段:它不是总点击次数,也不是已经造成伤害的次数。第三段完成后应回到 0,或者在连击窗口过期时回到 0。
comboTimer 与 comboTimeWindow
comboTimeWindow 是允许玩家继续连段的时间长度;comboTimer 是这个窗口当前还剩多少时间。倒计时的本质是:
Time.deltaTime 是受 Time.timeScale 影响的帧间隔。因此游戏暂停时,如果 timeScale 为 0,普通连击窗口也会暂停。比较器统一使用“小于等于 0”通常比“小于 0”更清楚。变量默认从 0 开始时,只判断小于 0 会让第一次攻击和第一次冲刺必须等到计时器经过一次减法后才满足条件,虽然通常只差一帧,但语义不够准确。
当前连击窗口的关键问题
当前窗口在 AttackAbility() 中开始:
这意味着攻击动画播放期间也在消耗连击窗口。例如 Attack1 长约 0.7 秒,如果 comboTimeWindow 只有 0.5 秒,第一段还没结束,窗口就已经过期。
如果“连击窗口”想表达的是上一段结束后,玩家还有多久可以按下一段,更合理的起点是 AttackOver():
倒计时只应在没有攻击时处理:
还有另一种设计:连击窗口不是从整段攻击结束后开始,而是由 Animation Event 在收招阶段调用
OpenComboWindow(),到另一个事件调用 CloseComboWindow()。这种方式能让手感直接与动画帧对应,但事件数量和状态管理会更多。当前实现不是输入缓冲
现在角色攻击期间再次点击鼠标,AttackAbility() 仍然会执行:
- comboCounter 尚未增加,所以不会自然切到下一段。
- isAttacking 原本已经是 true,再次赋值不会产生新的状态变化。
- comboTimer 会被重新设置,看起来像“记录了输入”,但实际上没有保存“下一段攻击已经排队”。
因此它不是输入缓冲,只是重复刷新计时器。
最简单的非缓冲方案是在攻击期间忽略新输入:
更自然的方案是增加
attackQueued:输入缓冲解决的是“玩家稍微提前按键也能接上连击”;连击窗口解决的是“上一段结束后多久之内还能继续”。两者不是同一个变量。
攻击动作的三个阶段
一段攻击通常可以拆成:
- 前摇 Startup:武器准备,通常还没有伤害。
- 有效帧 Active:Hitbox 生效,可以命中敌人。
- 后摇 Recovery:已经不能造成伤害,但角色还未完全恢复行动。
AttackOver 只表达“整段动作结束”。完整攻击以后还可能需要这些事件:
EnableHitbox():进入有效帧。
DisableHitbox():离开有效帧。
OpenComboWindow():允许缓存或接续下一段。
AttackOver():恢复普通行动。
PlayAttackSound()、SpawnEffect():让声音和特效与具体帧同步。
动画负责给出时间点,游戏逻辑负责决定这些时间点发生什么。
Animation Event 的实际原理
Animation Event 是保存在 Animation Clip 时间轴上的函数调用标记。播放头经过该时间点时,Unity 会在播放该动画的 GameObject 上查找同名回调。
你的 Animator 位于角色的视觉子对象,而 Player 脚本位于父对象。事件不会因为父对象上恰好有同名函数就自动向上查找,因此增加了桥接组件:
调用链是:
GetComponentInParent<Player>() 会从当前对象开始向父层级查找 Player 组件。这里用 Awake 比 Start 更适合初始化引用,因为 Awake 更早完成,能减少动画在初始化边界触发事件时引用仍为空的风险。Animation Event 回调可以不带参数,也可以接收一个 float、int、string、UnityEngine.Object 或 AnimationEvent 参数。攻击结束不需要额外数据,所以无参数回调最简单。
Animation Event 的边界
- Attack Clip 不应循环,否则事件可能每一轮都再次触发。
- 事件不宜压在 Clip 理论终点之外;放在最后一张有效 Sprite 附近通常更稳定。
- 如果状态在事件之前被 Jump、Hurt、Death 或其他 Any State Transition 打断,播放头不会经过事件点,AttackOver 就可能永远不执行。
- 函数改名、桥接脚本未挂在 Animator 对象、父对象没有 Player,都会导致事件没有接收者或空引用。
- 多个 Clip 都可以调用相同函数,因此桥接层适合只做转发,不应塞入大量攻击规则。
Animation Event 与 StateMachineBehaviour
Animation Event 适合“与某一帧严格同步”的行为,例如命中帧、脚步声和特效。
StateMachineBehaviour 的 OnStateEnter、OnStateExit 更适合“状态生命周期”:
- OnStateEnter:进入攻击状态。
- OnStateExit:攻击状态正常结束或被其他状态打断。
- 它不依赖播放头一定经过某个事件点。
更稳妥的分工是:
- 正常播放到末尾:Animation Event 调用
AttackOver(),完成连段计数。
- 被跳跃、受击、死亡等状态提前打断:OnStateExit 或取消逻辑调用
CancelAttack(),只清理 isAttacking 和 Hitbox,不增加 comboCounter。
不要让“正常完成”和“被迫中断”共用完全相同的结算逻辑,否则被打断的攻击也可能错误进入下一段。
Walk、Dash、Jump、Attack 的兼容性
攻击加入后,必须明确同一时刻谁能覆盖谁。下面描述的是当前代码和一种稳妥的基础策略,不是唯一玩法。
组合 | 当前代码表现 | 基础策略 |
Walk → Attack | isAttacking 优先,速度被设为 0,Walk 停止 | 允许进入攻击,并锁定水平移动 |
Attack → Walk | xInput 仍会读取,但 Movement 不采用它 | AttackOver 后恢复移动;需要更顺滑时缓存方向输入 |
Attack → Dash | DashAbility 已用 !isAttacking 阻止 | 保持阻止,或者以后显式设计“攻击取消进冲刺” |
Dash → Attack | AttackAbility 没有检查 dashTimer;两个参数可能同时为 true | 禁止冲刺中攻击,或攻击开始时主动把 dashTimer 清零,二选一 |
Attack → Jump | Jump 先写入向上速度,随后 Movement 又用 Vector2.zero 覆盖,按键被吞掉 | 明确禁止攻击中跳跃,或先 CancelAttack 再跳跃 |
Attack 中离开地面 | 每帧把 Y 速度设为 0,角色可能在边缘短暂悬空 | 只锁 X,保留 Y 速度;或者离地后立即取消地面攻击 |
Attack 中转向 | FlipController 仍读取 xInput,挥刀途中可以翻面 | 通常锁定攻击起始朝向,AttackOver 后再允许翻转 |
地面检测的原理参见 04 地面检测与跳跃。
当前代码中最容易出现的冲突
攻击会覆盖跳跃速度
Update 中先执行 CheckInput,再执行 Movement。攻击期间按 Jump 时:
这不是输入系统失效,而是后一次速度写入覆盖了前一次写入。
攻击会冻结重力结果
当前写法:
同时清除了水平和竖直速度。如果目的只是攻击期间不能走动,应保留物理系统计算的 Y 速度:
这样角色走出平台边缘后仍会正常下落。
冲刺与攻击参数可能同时成立
DashAbility 阻止“攻击中开始冲刺”,但 AttackAbility 没有阻止“冲刺中开始攻击”。此时:
- dashTimer 仍大于 0,所以 isDashing 为 true。
- isAttacking 也变成 true。
- Movement 因攻击分支优先而取消冲刺速度。
- Animator 中 Attack 和 Dash 的 Any State 条件可能同时参与评估。
应明确选择:
不要依赖 Animator Transition 排列顺序来偶然决定结果。
攻击被打断后可能卡住
AttackOver 只存在于攻击 Clip 的时间轴。如果角色在事件前离开地面,Any State → JumpAndFall 可能打断攻击,AttackOver 不再触发:
因此任何能打断攻击的路径,都必须执行统一的清理方法。
用互斥条件建立状态优先级
多个 Any State Transition 同时成立时,状态结果会受到条件、排列顺序和中断设置影响。更稳定的做法是让游戏逻辑先保证参数互斥。
一种基础优先级可以是:
它表达的是“同一帧发生冲突时谁取得控制权”,不是 Animator 图上节点的位置。具体游戏也可以允许跳跃取消攻击、冲刺取消后摇等,但每一种取消都必须显式写出:
- 允许从哪个阶段取消。
- 取消后清理哪些 Bool、Timer 和 Hitbox。
- 是否保留 comboCounter。
- 是否继承当前速度。
- 是否允许转向。
攻击动画不等于伤害判定
当前实现完成的是:
- 选择 Attack1 / 2 / 3 动画。
- 攻击期间锁定移动。
- 动画结束后推进 comboCounter。
它还没有真正检测敌人或造成伤害。完整攻击通常还需要:
- Hitbox:攻击能够命中的范围。
- Hurtbox:角色可以被攻击的范围。
- LayerMask:只查询敌人层,避免命中自己或地面。
- 伤害数据:每一段的伤害、击退、硬直和特效。
- 单次命中记录:同一个敌人在一次挥刀的多个有效帧中通常只能受击一次。
2D 项目常用 Physics2D.OverlapBox、OverlapCircle 或临时 Collider2D 查询目标。攻击点通常是 Visual 的子对象或单独的 AttackPoint,并使用 facingDir 决定左右偏移。
如果每一帧都执行范围检测却不记录已命中的 Collider,同一个敌人可能在一段攻击中连续扣血多次。可以在每次攻击开始时清空 HashSet,在有效帧中只处理尚未记录的目标。
Update、FixedUpdate 与 Animator 的职责
Input.GetKeyDown只在按键发生的那一帧为 true,应该在 Update 中读取。
- comboTimer 和 dashTimer 使用 Time.deltaTime 时也通常在 Update 中维护。
- Animator.SetBool、SetInteger 用于把这一帧最终游戏状态写入 Animator。
- Rigidbody2D 的持续运动更适合集中在 FixedUpdate 中处理,避免多个方法在同一帧互相覆盖速度。
- Animation Event 由动画播放时间触发,不保证与 FixedUpdate 同步,因此事件回调更适合修改状态标记,物理速度由统一的运动阶段读取这些标记后处理。
课程当前规模下,把 Movement 放在 Update 仍能工作;真正关键的是:同一帧只能有一个明确的速度决策出口,不能让 Jump、Dash、Attack 和普通移动各自无条件写 Rigidbody2D.velocity。
一种不带攻击取消的基础写法
下面只表达最基础的互斥策略:地面攻击期间不能跳跃、冲刺、转向或再次攻击;攻击结束后才开放连击窗口。
如果以后需要“跳跃取消攻击”“攻击取消冲刺”或“后摇期间允许转向”,应该在这个明确的基础上增加 CancelAttack、攻击阶段和输入缓冲,而不是继续叠加互相独立的 if。
相关 Unity API
Animator.SetBool:写入持续的 Animator 布尔参数。
Animator.SetInteger:写入离散的连段编号。
Input.GetKeyDown:捕获单帧按下事件。
Time.deltaTime:把按帧执行的倒计时转换成按秒计时。
GetComponentInChildren<Animator>():父对象取得视觉子对象上的 Animator。
GetComponentInParent<Player>():事件桥接组件向父层级寻找 Player。
Rigidbody2D.velocity:设置物理速度;后写入的值会覆盖先写入的值。
StateMachineBehaviour.OnStateExit:在动画状态退出或被打断时执行状态清理。
Physics2D.OverlapBox / OverlapCircle:以后实现攻击范围检测时查询 Collider2D。
LayerMask:限制攻击查询对象层级。