# 20 · 动画事件系统(Animation Event System) > **命名空间** `BaseGames.Animation` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Combat` · `BaseGames.Feedback` · `BaseGames.Audio` · Animancer Pro --- ## 目录 1. [系统总览](#1-系统总览) 2. [AnimancerEvent 注册规范](#2-animancerevent-注册规范) 3. [动画事件类型枚举](#3-动画事件类型枚举) 4. [AnimationEventConfigSO](#4-animationeventconfigso) 5. [PlayerAnimationEvents — 玩家事件处理器](#5-playeranimationevents--玩家事件处理器) 6. [EnemyAnimationEvents — 敌人事件处理器](#6-enemyanimationevents--敌人事件处理器) 7. [脚步声系统](#7-脚步声系统) 8. [可取消帧窗口](#8-可取消帧窗口) 9. [编辑器工具](#9-编辑器工具) --- ## 1. 系统总览 动画事件系统**统一管理**动画时间线上的游戏逻辑钩子,取代 Unity 传统的 `AnimationEvent`(字符串匹配)。 ``` 动画事件系统职责: ├─ AnimancerEvent 注册规范 → 在 ClipTransition 上直接绑定 C# 回调(类型安全) ├─ AnimationEventConfigSO → 每个动画片段的事件配置资产(策划可视化配置) ├─ PlayerAnimationEvents → 玩家 GameObject 上的统一事件分发器 ├─ EnemyAnimationEvents → 敌人 GameObject 上的统一事件分发器 ├─ 脚步声系统 → 表面检测 → 对应 AudioEventSO └─ 可取消帧窗口 → 攻击动画中特定帧后可取消进入其他状态 ``` **为什么不用传统 AnimationEvent**: - 字符串匹配无法重构、无智能感知 - 动画 clip 与 MonoBehaviour 产生隐式耦合 - Animancer `ClipTransition` 提供类型安全的 `Events` API,优先使用 --- ## 2. AnimancerEvent 注册规范 ### 2.1 运行时注册(代码方式) 在 State 的 `OnEnter` 中注册,`OnExit` 中清理: ```csharp // 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 时批量注册: ```csharp // 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. 动画事件类型枚举 ```csharp 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, // 通用完成信号(治疗动画结束等) } ``` ### 事件归一化时间辅助扩展 ```csharp public static class AnimationEventTypeExtensions { /// /// 通过 AnimationEventConfigSO 查询事件对应的归一化时间。 /// 如不存在配置,返回 -1(不注册)。 /// 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/`: ```csharp [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,统一接收所有来自动画的事件回调: ```csharp 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 — 敌人事件处理器 ```csharp 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 检测当前地面材质,播放对应音效: ```csharp 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(); 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. 可取消帧窗口 攻击动画进入**取消窗口**后,玩家输入下一个动作可立即取消当前攻击动画,实现流畅连招或紧急规避: ```csharp // 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(); else if (_inputBuffer.HasBuffered(InputBufferType.Dash)) _stateController.TransitionTo(); } 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` - 检测 `SpawnProjectile` payload 是否对应存在的 `ProjectileConfigSO` - 将问题输出到 Console,双击跳转到对应 SO