17 KiB
17 KiB
20 · 动画事件系统(Animation Event System)
命名空间
BaseGames.Animation
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Combat·BaseGames.Feedback·BaseGames.Audio· Animancer Pro
目录
- 系统总览
- AnimancerEvent 注册规范
- 动画事件类型枚举
- AnimationEventConfigSO
- PlayerAnimationEvents — 玩家事件处理器
- EnemyAnimationEvents — 敌人事件处理器
- 脚步声系统
- 可取消帧窗口
- 编辑器工具
1. 系统总览
动画事件系统统一管理动画时间线上的游戏逻辑钩子,取代 Unity 传统的 AnimationEvent(字符串匹配)。
动画事件系统职责:
├─ AnimancerEvent 注册规范 → 在 ClipTransition 上直接绑定 C# 回调(类型安全)
├─ AnimationEventConfigSO → 每个动画片段的事件配置资产(策划可视化配置)
├─ PlayerAnimationEvents → 玩家 GameObject 上的统一事件分发器
├─ EnemyAnimationEvents → 敌人 GameObject 上的统一事件分发器
├─ 脚步声系统 → 表面检测 → 对应 AudioEventSO
└─ 可取消帧窗口 → 攻击动画中特定帧后可取消进入其他状态
为什么不用传统 AnimationEvent:
- 字符串匹配无法重构、无智能感知
- 动画 clip 与 MonoBehaviour 产生隐式耦合
- Animancer
ClipTransition提供类型安全的EventsAPI,优先使用
2. AnimancerEvent 注册规范
2.1 运行时注册(代码方式)
在 State 的 OnEnter 中注册,OnExit 中清理:
// PlayerAttackState.cs
public class PlayerAttackState : PlayerStateBase
{
[SerializeField] ClipTransition _attackClip;
[SerializeField] PlayerAnimationEvents _animEvents;
public override void OnEnter()
{
// 直接在 ClipTransition 的 Events 上注册(类型安全,无字符串)
_attackClip.Events.SetCallback(
AnimationEventType.EnableHitBox.ToNormalizedTime(_attackClip),
_animEvents.OnEnableHitBox
);
_attackClip.Events.SetCallback(
AnimationEventType.DisableHitBox.ToNormalizedTime(_attackClip),
_animEvents.OnDisableHitBox
);
_attackClip.Events.SetCallback(
AnimationEventType.AttackImpact.ToNormalizedTime(_attackClip),
_animEvents.OnAttackImpact
);
Animancer.Play(_attackClip);
}
public override void OnExit()
=> _attackClip.Events.Clear();
}
2.2 配置资产方式(推荐,策划可调)
使用 AnimationEventConfigSO 存储每个事件在动画中的归一化时间点,由 AnimationEventBinder 在 Awake 时批量注册:
// AnimationEventBinder.cs(挂载于 PlayerController 上)
public class AnimationEventBinder : MonoBehaviour
{
[SerializeField] AnimationEventConfigSO[] _configs; // 拖入所有配置资产
[SerializeField] AnimancerComponent _animancer;
[SerializeField] PlayerAnimationEvents _handler;
void Awake()
{
foreach (var config in _configs)
config.RegisterAll(_animancer, _handler);
}
}
3. 动画事件类型枚举
public enum AnimationEventType
{
// ── 战斗 ──
EnableHitBox, // 开启攻击判定框
DisableHitBox, // 关闭攻击判定框
AttackImpact, // 攻击冲击帧(生成打击 VFX)
SpawnProjectile, // 生成弹射物(由 Pool 提供)
// ── 移动/物理 ──
Footstep, // 脚步(触发地表音效检测)
LandImpact, // 落地冲击(粒子 + 震屏)
JumpLaunch, // 起跳帧(可添加跳跃音效/粒子)
// ── 状态控制 ──
EnableCancelWindow, // 从此帧开始可以取消当前动画进入其他状态
DisableCancelWindow,// 取消窗口结束
StateTransition, // 强制转换到指定状态(用于动画驱动的状态机转换)
EnableParryWindow, // 开启弹反判定窗口(帧嵌入弹反:直接进入 Active)
DisableParryWindow, // 关闭帧嵌入弹反窗口
// ── 反馈 ──
TriggerFeedback, // 触发指定 FeedbackPresetSO 的 MMF_Player
PlaySFX, // 播放指定 AudioEventSO
// ── 过场/交互 ──
EnableInteract, // NPC 动画完毕后开启 InteractableNPC 的交互提示
AnimationComplete, // 通用完成信号(治疗动画结束等)
}
事件归一化时间辅助扩展
public static class AnimationEventTypeExtensions
{
/// <summary>
/// 通过 AnimationEventConfigSO 查询事件对应的归一化时间。
/// 如不存在配置,返回 -1(不注册)。
/// </summary>
public static float ToNormalizedTime(
this AnimationEventType type,
ClipTransition clip,
AnimationEventConfigSO config = null)
{
if (config == null) return -1f;
return config.GetNormalizedTime(clip.Clip, type);
}
}
4. AnimationEventConfigSO
每个动画片段(ClipTransition)对应一个配置资产,存放在 Assets/ScriptableObjects/AnimationEvents/:
[CreateAssetMenu(menuName = "Animation/AnimationEventConfig")]
public class AnimationEventConfigSO : ScriptableObject
{
[Header("绑定的动画 Clip")]
public AnimationClip targetClip;
[Header("事件列表")]
public AnimationEventEntry[] events;
public void RegisterAll(AnimancerComponent animancer, IAnimationEventHandler handler)
{
if (!animancer.States.TryGet(targetClip, out var state)) return;
foreach (var entry in events)
{
var localEntry = entry; // 闭包捕获
animancer.States[targetClip].Events.Add(
entry.normalizedTime,
() => handler.HandleEvent(localEntry.eventType, localEntry.payload)
);
}
}
public float GetNormalizedTime(AnimationClip clip, AnimationEventType type)
{
if (clip != targetClip) return -1f;
foreach (var e in events)
if (e.eventType == type) return e.normalizedTime;
return -1f;
}
}
[Serializable]
public struct AnimationEventEntry
{
[Range(0f, 1f)]
public float normalizedTime; // 在动画中的触发时间点(0~1)
public AnimationEventType eventType;
public string payload; // 可选载荷(SFX ID / FeedbackPreset ID)
}
典型配置资产布局
Assets/ScriptableObjects/AnimationEvents/
├── Player/
│ ├── Attack1_Events.asset → EnableHitBox(0.25), DisableHitBox(0.55), AttackImpact(0.30), EnableCancelWindow(0.60)
│ ├── Attack2_Events.asset → EnableParryWindow(0.05), DisableParryWindow(0.18), EnableHitBox(0.20), DisableHitBox(0.60), AttackImpact(0.25), EnableCancelWindow(0.65)
│ ├── Attack3_Events.asset → EnableParryWindow(0.05), DisableParryWindow(0.12), EnableHitBox(0.15), DisableHitBox(0.70), AttackImpact(0.20)(连击终结,无取消窗口)
│ ├── Dash_Events.asset → EnableCancelWindow(0.50)
│ ├── Run_Events.asset → Footstep(0.15), Footstep(0.65)(左右脚步)
│ ├── Jump_Events.asset → JumpLaunch(0.10)
│ ├── Land_Events.asset → LandImpact(0.05)
│ ├── Heal_Events.asset → AnimationComplete(0.95)
│ └── Parry_Events.asset → TriggerFeedback(0.10, "ParryPose")(专用弹反由代码驱动,无需 EnableParryWindow)
│
└── Enemies/
├── SpiderScout_Attack_Events.asset → EnableHitBox(0.40), DisableHitBox(0.70)
├── KnightElite_Slash_Events.asset → EnableHitBox(0.30), DisableHitBox(0.60), SpawnProjectile(0.50)
└── Boss_MeleeLunge_Events.asset → EnableHitBox(0.35), DisableHitBox(0.65), AttackImpact(0.40)
5. PlayerAnimationEvents — 玩家事件处理器
PlayerAnimationEvents 挂载于 PlayerController 根 GameObject,统一接收所有来自动画的事件回调:
public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler
{
[Header("战斗组件")]
[SerializeField] PlayerCombat _combat;
[SerializeField] HitBox _hitBox;
[SerializeField] ParrySystem _parrySystem;
[Header("移动")]
[SerializeField] PlayerMovement _movement;
[Header("反馈")]
[SerializeField] PlayerFeedback _feedback;
[Header("音效")]
[SerializeField] FootstepSystem _footstep;
[Header("投射物")]
[SerializeField] ProjectileManager _projectileManager;
[Header("SO 频道")]
[SerializeField] VoidEventChannelSO _onHealComplete;
public void HandleEvent(AnimationEventType type, string payload)
{
switch (type)
{
case AnimationEventType.EnableHitBox:
_hitBox.Enable();
break;
case AnimationEventType.DisableHitBox:
_hitBox.Disable();
break;
case AnimationEventType.AttackImpact:
_feedback.PlayAttackWhoosh();
break;
case AnimationEventType.SpawnProjectile:
_projectileManager.SpawnFromPlayer(payload); // payload = projectileConfigId
break;
case AnimationEventType.Footstep:
_footstep.OnFootstep();
break;
case AnimationEventType.LandImpact:
_feedback.PlayLandImpact();
break;
case AnimationEventType.JumpLaunch:
_feedback.PlayJumpLaunch();
break;
case AnimationEventType.EnableCancelWindow:
_combat.BeginCancelWindow();
break;
case AnimationEventType.DisableCancelWindow:
_combat.EndCancelWindow();
break;
case AnimationEventType.EnableParryWindow:
_parrySystem.OpenWindow();
break;
case AnimationEventType.DisableParryWindow:
_parrySystem.CloseWindow();
break;
case AnimationEventType.TriggerFeedback:
_feedback.TriggerPreset(payload); // payload = presetId
break;
case AnimationEventType.PlaySFX:
_feedback.PlaySFXById(payload); // payload = audioEventSO name
break;
case AnimationEventType.AnimationComplete:
_onHealComplete?.Raise(); // 治疗动画完毕 → 恢复 HP
break;
}
}
// 直接回调版本(性能更高,不走 switch,适合热路径)
public void OnEnableHitBox() => _hitBox.Enable();
public void OnDisableHitBox() => _hitBox.Disable();
public void OnAttackImpact() => _feedback.PlayAttackWhoosh();
}
public interface IAnimationEventHandler
{
void HandleEvent(AnimationEventType type, string payload);
}
6. EnemyAnimationEvents — 敌人事件处理器
public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler
{
[SerializeField] EnemyBase _enemy;
[SerializeField] HitBox _hitBox;
[SerializeField] EnemyFeedback _feedback;
[SerializeField] FootstepSystem _footstep;
public void HandleEvent(AnimationEventType type, string payload)
{
switch (type)
{
case AnimationEventType.EnableHitBox:
_hitBox.Enable();
break;
case AnimationEventType.DisableHitBox:
_hitBox.Disable();
break;
case AnimationEventType.SpawnProjectile:
_enemy.SpawnProjectile(payload); // EnemyBase 负责池化与方向计算
break;
case AnimationEventType.TriggerFeedback:
_feedback.TriggerPreset(payload);
break;
case AnimationEventType.Footstep:
_footstep.OnFootstep();
break;
case AnimationEventType.AnimationComplete:
_enemy.OnActionAnimationComplete(); // 通知 BT 当前 Action 完成
break;
}
}
}
7. 脚步声系统
FootstepSystem 通过 Raycast 检测当前地面材质,播放对应音效:
public class FootstepSystem : MonoBehaviour
{
[Header("检测")]
[SerializeField] Transform _foot; // 脚部 Transform
[SerializeField] float _rayLength = 0.2f;
[SerializeField] LayerMask _groundLayer;
[Header("地面音效映射")]
[SerializeField] SurfaceAudioMapSO _surfaceMap;
public void OnFootstep()
{
var hit = Physics2D.Raycast(_foot.position, Vector2.down, _rayLength, _groundLayer);
if (!hit) return;
var surface = hit.collider.GetComponent<SurfaceTag>();
var sfx = _surfaceMap.GetSFX(surface?.surfaceType ?? SurfaceType.Stone);
sfx?.Raise();
}
}
[CreateAssetMenu(menuName = "Audio/SurfaceAudioMap")]
public class SurfaceAudioMapSO : ScriptableObject
{
[Serializable]
public struct SurfaceEntry
{
public SurfaceType type;
public AudioEventSO sfx;
}
public SurfaceEntry[] entries;
public AudioEventSO GetSFX(SurfaceType type)
{
foreach (var e in entries)
if (e.type == type) return e.sfx;
return null;
}
}
public enum SurfaceType
{
Stone, // 石地:清脆踩踏声
Dirt, // 泥土:低沉踩踏声
Grass, // 草地:柔软踩踏声
Metal, // 金属:铿锵声
Wood, // 木头:空洞踩踏声
Water, // 浅水:泼溅声
Crystal, // 水晶:高频玻璃声
}
8. 可取消帧窗口
攻击动画进入取消窗口后,玩家输入下一个动作可立即取消当前攻击动画,实现流畅连招或紧急规避:
// PlayerCombat.cs — 扩展取消窗口管理
bool _isCancelWindowOpen;
InputBuffer _inputBuffer; // 注入
public void BeginCancelWindow()
{
_isCancelWindowOpen = true;
// 立即消费输入缓冲中已积累的动作
ConsumeBufferedInput();
}
public void EndCancelWindow()
=> _isCancelWindowOpen = false;
public bool CanCancelInto(CancelTarget target)
=> _isCancelWindowOpen && target switch
{
CancelTarget.Attack => true, // 下一攻击
CancelTarget.Dash => _playerStats.HasAbility("Dash"),
CancelTarget.Parry => true,
_ => false,
};
void ConsumeBufferedInput()
{
if (_inputBuffer.HasBuffered(InputBufferType.Attack))
_stateController.TransitionTo<PlayerAttackState>();
else if (_inputBuffer.HasBuffered(InputBufferType.Dash))
_stateController.TransitionTo<PlayerDashState>();
}
public enum CancelTarget { Attack, Dash, Parry, Jump }
取消窗口时序规范:
| 攻击 | 取消窗口开始(归一化时间) | 说明 |
|---|---|---|
| 攻击1 | 0.60 | 命中后可取消,节奏轻快 |
| 攻击2 | 0.65 | 稍晚,给动画时间 |
| 攻击3(终结) | 无 | 终结技无取消窗口,需等动画完毕 |
| 冲刺 | 0.50 | 冲刺中途可接攻击 |
| 治疗 | 0.80 | 治疗接近完成时才可取消(防止滥用) |
9. 编辑器工具
AnimationEventPreview — ClipTransition 扩展 Inspector
AnimationEventConfigSO 自定义 Inspector 显示时间轴预览:
┌─ AnimationEventConfigSO: Attack1_Events ──────────────────┐
│ Clip: PlayerAttack1 (0.467s) │
│ │
│ 0.0 ────────────────────────────────────────────── 1.0 │
│ │EnableHB│ │DisableHB│ │
│ 0.25 │ImpactFX│ 0.55 │CancelWin│ │
│ 0.30 0.60 │
│ │
│ [+添加事件] [在 Animancer 预览窗口中定位] │
└────────────────────────────────────────────────────────────┘
AnimationEvent 全局扫描器
Tools > Zeling > Animation Event Scanner:
- 扫描所有
AnimationEventConfigSO资产 - 检测事件时间点是否超出动画时长(归一化 > 1.0)
- 检测是否有
EnableHitBox没有对应的DisableHitBox - 检测
SpawnProjectilepayload 是否对应存在的ProjectileConfigSO - 将问题输出到 Console,双击跳转到对应 SO