# 24 · 动画事件模块(Animation Event Module) > **命名空间** `BaseGames.Animation` > **程序集** `BaseGames.Player` + `BaseGames.Enemy`(各自引用,共享接口) > **依赖** `Animancer Pro`(ClipTransition · Events API)· `BaseGames.Core.Events` · `BaseGames.Combat`(HitBox · HurtBox) > **Design 来源** [20_AnimationEventSystem](../Design/20_AnimationEventSystem.md) --- ## 目录 1. [模块职责](#1-模块职责) 2. [AnimationEventType 枚举](#2-animationeventtype-枚举) 3. [AnimationEventConfigSO](#3-animationeventconfigso) 4. [Animancer 事件注册模式](#4-animancer-事件注册模式) 5. [PlayerAnimationEvents](#5-playeranimationevents) 6. [EnemyAnimationEvents](#6-enemyanimationevents) 7. [AnimationEventBinder](#7-animationeventbinder) 8. [脚步系统](#8-脚步系统) 9. [可取消帧窗口(Cancel Window)](#9-可取消帧窗口cancel-window) 10. [编辑器工具:EventConfigEditor](#10-编辑器工具eventconfigeditor) --- ## 1. 模块职责 ``` 动画事件模块职责: ├─ AnimationEventType → 统一事件类型枚举(替代字符串 AnimationEvent) ├─ AnimationEventConfigSO → 每个 ClipTransition 的事件时机配置(给设计师调整) ├─ AnimationEventBinder → 读取 Config,向 Animancer ClipTransition 注册回调 ├─ PlayerAnimationEvents → 玩家侧统一派发器(HitBox/HurtBox/音效/特效 的单一入口) └─ EnemyAnimationEvents → 敌人侧统一派发器 ``` **核心原则**: - **不使用** Unity 传统 `AnimationEvent`(字符串反射调用) - **使用** Animancer `ClipTransition.Events.SetCallback(normalizedTime, callback)` — 类型安全 - 所有时机值由 `AnimationEventConfigSO` 管理,设计师可调整而无需修改代码 --- ## 2. AnimationEventType 枚举 ```csharp namespace BaseGames.Animation { public enum AnimationEventType { // 战斗 - HitBox EnableHitBox, // 开启 HitBox(攻击帧开始) DisableHitBox, // 关闭 HitBox(攻击帧结束) AttackImpact, // 攻击命中反馈(音效/特效时机) // 战斗 - 弹反窗口(由 ParrySystem 监听) EnableParryWindow, // 开启可弹反时间窗(ParrySystem.OpenWindow()) DisableParryWindow, // 关闭可弹反时间窗(ParrySystem.CloseWindow()) // 玩家 EnableIFrame, // 开启无敌帧(翻滚/受击恢复) DisableIFrame, // 关闭无敌帧 Footstep, // 脚步落地(替代旧 FootstepLeft/Right,由 data 区分面) LandImpact, // 落地震动(落地音效/特效) JumpLaunch, // 起跳(跳跃音效/特效) EnableInteract, // 动画帧触发互动(如 NPC 握手时机) // 反馈派发 TriggerFeedback, // 触发 MMF_Player 预设(data = Feedback 名称) PlaySFX, // 播放音效(data = AudioEventSO Address key) // 敌人 SpawnProjectile, // 生成弹幕(替代旧 SummonProjectile) RoarStart, // 怒吼开始(AI 警觉) RoarEnd, // 怒吼结束 PhaseTwoStart, // 二阶段开始(Boss 过渡) // 通用 CancelWindowOpen, // 可取消帧窗口开始 CancelWindowClose, // 可取消帧窗口结束 StateTransition, // 动画驱动状态机转移(data = 目标状态名) AnimationComplete, // 动画播完(一次性动画通知) } } ``` --- ## 3. AnimationEventConfigSO ```csharp namespace BaseGames.Animation { [CreateAssetMenu(menuName = "Animation/EventConfig")] public class AnimationEventConfigSO : ScriptableObject { [Serializable] public struct EventEntry { public AnimationEventType eventType; [Range(0f, 1f)] public float normalizedTime; // 触发帧在整个动画中的归一化位置 [Tooltip("附加数据(可空):如 HitBox 编号、音频 key 等")] public string data; } [Header("绑定的动画片段(类型安全引用,替代旧 clipName 字符串)")] public AnimationClip targetClip; // 用于 GetNormalizedTime 查询 [Header("事件时机列表")] public EventEntry[] events; /// 按时机顺序排序,方便 Binder 批量注册。 public IEnumerable SortedEvents => events.OrderBy(e => e.normalizedTime); /// 查询指定事件类型的触发帧(工具/编辑器用)。 public float GetNormalizedTime(AnimationEventType eventType) { foreach (var e in events) if (e.eventType == eventType) return e.normalizedTime; return -1f; // 未找到 } } } ``` --- ## 4. Animancer 事件注册模式 ```csharp // AnimationEventBinder.Bind(ClipTransition, AnimationEventConfigSO, IAnimationEventHandler) // 将 Config 中的事件条目全部注册到指定 ClipTransition 上。 namespace BaseGames.Animation { public static class AnimationEventBinder { /// /// 将 SO 配置的所有事件绑定到 ClipTransition。 /// 由 PlayerAnimationEvents / EnemyAnimationEvents 在 Awake 时调用。 /// public static void Bind( ClipTransition clip, AnimationEventConfigSO config, IAnimationEventHandler receiver) { if (config == null) return; foreach (var entry in config.SortedEvents) { var captured = entry; // 闭包捕获 clip.Events.SetCallback(captured.normalizedTime, () => receiver.HandleEvent(captured.eventType, captured.data)); } } } /// 实现此接口的 MonoBehaviour 可接收动画事件回调。 public interface IAnimationEventHandler { void HandleEvent(AnimationEventType type, string payload); } } ``` --- ## 5. PlayerAnimationEvents ```csharp namespace BaseGames.Animation { /// /// 挂在 PlayerController 上(或其 [Animation] 子节点)。 /// 是玩家所有动画事件的唯一派发入口。 /// public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler { [Header("战斗组件")] [SerializeField] HitBox[] _hitBoxes; // 玩家身上所有 HitBox [SerializeField] HurtBox _hurtBox; [Header("能力组件")] [SerializeField] PlayerStats _playerStats; // 用于开关无敌帧 [SerializeField] PlayerMovement _mover; // ⚠️ 类型为 PlayerMovement(架构 05_PlayerModule §3) [SerializeField] ParrySystem _parrySystem; // 弹反窗口控制(20_ShieldModule §1) [Header("特效/音效")] [SerializeField] IFeedbackPlayer _feedback; // 通过 GetComponent 注入 [Header("事件配置(与 ClipTransition 一一对应)")] [SerializeField] EventBinding[] _bindings; [Serializable] struct EventBinding { public ClipTransition clip; public AnimationEventConfigSO config; } void Awake() { _feedback = GetComponentInParent(); foreach (var b in _bindings) AnimationEventBinder.Bind(b.clip, b.config, this); } public void HandleEvent(AnimationEventType type, string payload) { switch (type) { case AnimationEventType.EnableHitBox: EnableHitBoxById(payload); break; case AnimationEventType.DisableHitBox: DisableHitBoxById(payload); break; case AnimationEventType.AttackImpact: _feedback?.PlayAttackWhoosh(); break; case AnimationEventType.EnableIFrame: _hurtBox.SetInvincible(true); break; case AnimationEventType.DisableIFrame: _hurtBox.SetInvincible(false); break; case AnimationEventType.Footstep: FootstepSystem.Play(_mover.CurrentSurface, transform.position); break; case AnimationEventType.LandImpact: _feedback?.PlayLandImpact(); break; case AnimationEventType.JumpLaunch: _feedback?.PlayJumpLaunch(); break; case AnimationEventType.EnableParryWindow: _parrySystem?.OpenWindow(); break; case AnimationEventType.DisableParryWindow: _parrySystem?.CloseWindow(); break; case AnimationEventType.CancelWindowOpen: _mover.SetCancelWindowOpen(true); break; case AnimationEventType.CancelWindowClose: _mover.SetCancelWindowOpen(false); break; case AnimationEventType.TriggerFeedback: _feedback?.TriggerPreset(payload); // IFeedbackPlayer.TriggerPreset(见 18_VFXFeedbackModule §2) break; case AnimationEventType.PlaySFX: // payload = AudioEventSO Address key(IFeedbackPlayer.PlaySFXById 内部查表后播放) _feedback?.PlaySFXById(payload); // IFeedbackPlayer.PlaySFXById(见 18_VFXFeedbackModule §2) break; } } // ── 辅助 ────────────────────────────────────────────────── void EnableHitBoxById(string id) { foreach (var hb in _hitBoxes) if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Activate(); // ⚠️ HitBox.Activate(),无参数(架构 06 §4 参数均可空) } void DisableHitBoxById(string id) { foreach (var hb in _hitBoxes) if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4) } } } ``` --- ## 6. EnemyAnimationEvents ```csharp namespace BaseGames.Animation { /// /// 挂在 EnemyBase 上(或其 [Animation] 子节点)。 /// 与 PlayerAnimationEvents 结构相同,处理敌人侧事件。 /// public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler { [SerializeField] HitBox[] _hitBoxes; [SerializeField] EnemyFeedback _feedback; [SerializeField] EnemyBase _enemy; [SerializeField] EventBinding[] _bindings; [Serializable] struct EventBinding { public ClipTransition clip; public AnimationEventConfigSO config; } void Awake() { foreach (var b in _bindings) AnimationEventBinder.Bind(b.clip, b.config, this); } public void HandleEvent(AnimationEventType type, string payload) { switch (type) { case AnimationEventType.EnableHitBox: foreach (var hb in _hitBoxes) if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Activate(); // ⚠️ HitBox.Activate()(架构 06 §4) break; case AnimationEventType.DisableHitBox: foreach (var hb in _hitBoxes) if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4) break; case AnimationEventType.SpawnProjectile: _enemy.SpawnProjectile(payload); break; case AnimationEventType.RoarStart: case AnimationEventType.RoarEnd: // 通知 AI Blackboard _enemy.Blackboard.SetVariableValue("IsRoaring", type == AnimationEventType.RoarStart); break; case AnimationEventType.PhaseTwoStart: _enemy.TriggerPhaseTwo(); break; case AnimationEventType.AnimationComplete: _enemy.OnAnimationComplete(payload); break; } } } } ``` --- ## 7. AnimationEventBinder > 已在 §4 完整定义(`static Bind(ClipTransition, Config, Receiver)`)。 **使用示例(PlayerAnimationEvents.Awake 之外的独立绑定)**: ```csharp // 在需要运行时动态替换 Config 的场合 AnimationEventBinder.Bind(_slashClip, _hardModeConfig, this); ``` --- ## 8. 脚步系统 ```csharp namespace BaseGames.Animation { /// /// 静态工具类,根据地面材质播放对应的脚步音效/特效。 /// public static class FootstepSystem { static FootstepCatalogSO _catalog; // 禁止使用 Resources.Load。 // 在游戏启动时由 BootstrapLoader(或 PersistentScene)调用 InitAsync 预加载。 public static async UniTask InitAsync() { _catalog = await Addressables .LoadAssetAsync(AddressKeys.SO_FootstepCatalog).Task; } public static void Play(SurfaceType surface, Vector3 position) { var entry = _catalog.GetEntry(surface); if (entry == null) return; // 播放音效(通过 AudioManager)——使用 AudioClip 引用,API 与 11_AudioModule §2 一致 AudioManager.Instance.PlaySFXAtPosition(entry.audioClip, position); // 播放粒子(通过 VFXPool) if (entry.dustParticlePrefab.RuntimeKeyIsValid()) VFXPool.Instance.Play(entry.dustParticlePrefab, position); } } // FootstepCatalogSO 与 FootstepEntry 见 Assets/ScriptableObjects/Audio/FootstepCatalog.asset [CreateAssetMenu(menuName = "Audio/FootstepCatalog")] public class FootstepCatalogSO : ScriptableObject { [Serializable] public struct FootstepEntry { public SurfaceType surface; public AudioClip audioClip; // ⚠️ AudioClip 引用,非 string key(11_AudioModule §2:PlaySFXAtPosition(AudioClip, Vector2)) public AssetReferenceGameObject dustParticlePrefab; } [SerializeField] FootstepEntry[] _entries; public FootstepEntry? GetEntry(SurfaceType surface) { foreach (var e in _entries) if (e.surface == surface) return e; return null; } } public enum SurfaceType { Stone, Wood, Dirt, Water, Metal, Grass } } ``` --- ## 9. 可取消帧窗口(Cancel Window) ```csharp // PlayerMovement.cs 中的 Cancel Window 字段(见架构 05_PlayerModule §3) bool _cancelWindowOpen = false; public bool CancelWindowOpen => _cancelWindowOpen; public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open; // 玩家 FSM:在 AttackState.OnUpdate() 末尾检查 public override PlayerStateBase GetNextState() { // 只有在 CancelWindowOpen 时才接受新输入转换状态 if (!_mover.CancelWindowOpen) return null; if (Input.AttackPressed) return _nextAttackState; if (Input.DashPressed) return _dashState; if (Input.JumpPressed) return _jumpState; return null; } ``` **流程**: ``` AttackClip 播放 ├─ NormalizedTime = 0.6 → AnimationEventType.CancelWindowOpen → PlayerMovement._cancelWindowOpen = true ├─ 玩家可输入 → FSM 允许取消进入其他状态 └─ NormalizedTime = 0.9 → AnimationEventType.CancelWindowClose → PlayerMover._cancelWindowOpen = false ``` --- ## 10. 编辑器工具:EventConfigEditor > **路径**:`Assets/Editor/Animation/EventConfigEditor.cs` > **目标用户**:动画师 / 战斗设计师(调整攻击命中帧、特效触发时机无需对照文本数值) ### 功能特性 | 功能 | 说明 | |------|------| | **时间轴可视化** | 按 `normalizedTime` 绘制可拖拽标记点,直观感受帧时机 | | **Clip 漂移检测** | Clip 长度与记录值偏差 > 5 帧时显示橙色警告,通知动画师同步确认 | | **Auto-detect** | 从 `AnimationClip` 自动读取 FPS/Duration,写入 `ExpectedClipLength` | | **事件类型着色** | 不同 `AnimationEventType` 使用不同颜色(HitBox=红, IFrame=绿, SFX=蓝)| | **越界保护** | `normalizedTime < 0 || > 1` 时显示红色错误行 | ### AnimationEventConfigSO 新增字段 ```csharp // 路径: Assets/Scripts/Animation/AnimationEventConfigSO.cs(新增字段) [HideInInspector] public float ExpectedClipLength = -1f; // 帧数;-1=未记录;Auto-detect 写入 ``` ### EventConfigEditor 实现 ```csharp // 路径: Assets/Editor/Animation/EventConfigEditor.cs [CustomEditor(typeof(AnimationEventConfigSO))] public class EventConfigEditor : UnityEditor.Editor { private static readonly Dictionary _typeColors = new() { { AnimationEventType.EnableHitBox, new Color(1f, 0.3f, 0.3f) }, // 红 - 攻击激活 { AnimationEventType.DisableHitBox, new Color(0.8f, 0.2f, 0.2f) }, { AnimationEventType.EnableIFrame, new Color(0.3f, 0.9f, 0.3f) }, // 绿 - 无敌帧 { AnimationEventType.DisableIFrame, new Color(0.2f, 0.7f, 0.2f) }, { AnimationEventType.EnableParryWindow, new Color(0.9f, 0.8f, 0.1f) }, // 黄 - 弹反窗口 { AnimationEventType.Footstep, new Color(0.7f, 0.7f, 0.7f) }, // 灰 - 音效 { AnimationEventType.PlaySFX, new Color(0.4f, 0.7f, 1f) }, // 蓝 - 音效 { AnimationEventType.TriggerFeedback, new Color(0.8f, 0.5f, 1f) }, // 紫 - Feel { AnimationEventType.CancelWindowOpen, new Color(1f, 0.7f, 0.3f) }, // 橙 - 取消窗口 }; public override void OnInspectorGUI() { var config = (AnimationEventConfigSO)target; var prop = serializedObject; prop.Update(); // ── Clip 引用 + Auto-detect ─────────────────────────────────────── EditorGUILayout.PropertyField(prop.FindProperty("targetClip")); if (config.targetClip != null) { float currentLen = config.targetClip.length * config.targetClip.frameRate; float expected = config.ExpectedClipLength; EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField($"Clip: {currentLen:F0}帧 / {config.targetClip.length:F3}s", EditorStyles.miniLabel); if (GUILayout.Button("Auto-detect", GUILayout.Width(90))) { config.ExpectedClipLength = currentLen; EditorUtility.SetDirty(config); } EditorGUILayout.EndHorizontal(); // 漂移检测:偏差 > 5 帧警告 if (expected > 0 && Mathf.Abs(currentLen - expected) > 5f) { EditorGUILayout.HelpBox( $"⚠️ Clip 长度已变更(原 {expected:F0} 帧 → 当前 {currentLen:F0} 帧)\n" + "请检查各事件 normalizedTime 时机是否需要同步调整。", MessageType.Warning); } } EditorGUILayout.Space(8); // ── 时间轴可视化 ───────────────────────────────────────────────── EditorGUILayout.LabelField("事件时间轴", EditorStyles.boldLabel); // 时间轴背景条(全宽) var timelineRect = GUILayoutUtility.GetRect(0, 24, GUILayout.ExpandWidth(true)); EditorGUI.DrawRect(timelineRect, new Color(0.15f, 0.15f, 0.15f)); // 每个事件绘制一条竖线标记 if (config.events != null) { foreach (var entry in config.events) { float t = Mathf.Clamp01(entry.normalizedTime); float x = timelineRect.x + t * timelineRect.width; var lineRect = new Rect(x - 1, timelineRect.y, 2, timelineRect.height); Color c = _typeColors.TryGetValue(entry.eventType, out var col) ? col : Color.white; EditorGUI.DrawRect(lineRect, c); } } EditorGUILayout.Space(4); // ── 事件列表(含越界红色标记) ────────────────────────────────── var eventsProp = prop.FindProperty("events"); EditorGUILayout.PropertyField(eventsProp, new GUIContent("事件条目"), true); // 遍历检测越界 if (config.events != null) { foreach (var e in config.events) { if (e.normalizedTime < 0f || e.normalizedTime > 1f) EditorGUILayout.HelpBox( $"❌ [{e.eventType}] normalizedTime={e.normalizedTime:F3} 超出 [0, 1] 范围!", MessageType.Error); } } prop.ApplyModifiedProperties(); } } ``` --- ## Player Prefab 层级更新 ``` [Player] ├── PlayerController.cs ├── [Animation] ← 新增子节点 │ ├── PlayerAnimationEvents.cs │ │ ├── _hitBoxes: [HitBox] 引用 │ │ ├── _hurtBox: HurtBox 引用 │ │ └── _bindings: ClipTransition → AnimationEventConfigSO 映射 │ └── AnimancerComponent.cs └── [Combat] ├── HitBox.cs(id 字段,由 AnimationEventConfig 的 data 匹配) └── HurtBox.cs ```