Files
zeling_v2/Docs/Architecture/24_AnimEventModule.md
2026-05-08 11:04:00 +08:00

22 KiB
Raw Permalink Blame History

24 · 动画事件模块Animation Event Module

命名空间 BaseGames.Animation
程序集 BaseGames.Player + BaseGames.Enemy(各自引用,共享接口)
依赖 Animancer ProClipTransition · Events API· BaseGames.Core.Events · BaseGames.CombatHitBox · HurtBox
Design 来源 20_AnimationEventSystem


目录

  1. 模块职责
  2. AnimationEventType 枚举
  3. AnimationEventConfigSO
  4. Animancer 事件注册模式
  5. PlayerAnimationEvents
  6. EnemyAnimationEvents
  7. AnimationEventBinder
  8. 脚步系统
  9. 可取消帧窗口Cancel Window
  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 枚举

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

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;

        /// <summary>按时机顺序排序,方便 Binder 批量注册。</summary>
        public IEnumerable<EventEntry> SortedEvents =>
            events.OrderBy(e => e.normalizedTime);

        /// <summary>查询指定事件类型的触发帧(工具/编辑器用)。</summary>
        public float GetNormalizedTime(AnimationEventType eventType)
        {
            foreach (var e in events)
                if (e.eventType == eventType) return e.normalizedTime;
            return -1f;  // 未找到
        }
    }
}

4. Animancer 事件注册模式

// AnimationEventBinder.Bind(ClipTransition, AnimationEventConfigSO, IAnimationEventHandler)
// 将 Config 中的事件条目全部注册到指定 ClipTransition 上。

namespace BaseGames.Animation
{
    public static class AnimationEventBinder
    {
        /// <summary>
        /// 将 SO 配置的所有事件绑定到 ClipTransition。
        /// 由 PlayerAnimationEvents / EnemyAnimationEvents 在 Awake 时调用。
        /// </summary>
        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));
            }
        }
    }

    /// <summary>实现此接口的 MonoBehaviour 可接收动画事件回调。</summary>
    public interface IAnimationEventHandler
    {
        void HandleEvent(AnimationEventType type, string payload);
    }
}

5. PlayerAnimationEvents

namespace BaseGames.Animation
{
    /// <summary>
    /// 挂在 PlayerController 上(或其 [Animation] 子节点)。
    /// 是玩家所有动画事件的唯一派发入口。
    /// </summary>
    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<IFeedbackPlayer>();
            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 keyIFeedbackPlayer.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

namespace BaseGames.Animation
{
    /// <summary>
    /// 挂在 EnemyBase 上(或其 [Animation] 子节点)。
    /// 与 PlayerAnimationEvents 结构相同,处理敌人侧事件。
    /// </summary>
    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 之外的独立绑定)

// 在需要运行时动态替换 Config 的场合
AnimationEventBinder.Bind(_slashClip, _hardModeConfig, this);

8. 脚步系统

namespace BaseGames.Animation
{
    /// <summary>
    /// 静态工具类,根据地面材质播放对应的脚步音效/特效。
    /// </summary>
    public static class FootstepSystem
    {
        static FootstepCatalogSO _catalog;

        // 禁止使用 Resources.Load。
        // 在游戏启动时由 BootstrapLoader或 PersistentScene调用 InitAsync 预加载。
        public static async UniTask InitAsync()
        {
            _catalog = await Addressables
                .LoadAssetAsync<FootstepCatalogSO>(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 key11_AudioModule §2PlaySFXAtPosition(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

// 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

AnimationEventConfigSO 新增字段

// 路径: Assets/Scripts/Animation/AnimationEventConfigSO.cs新增字段
[HideInInspector] public float ExpectedClipLength = -1f;  // 帧数;-1=未记录Auto-detect 写入

EventConfigEditor 实现

// 路径: Assets/Editor/Animation/EventConfigEditor.cs
[CustomEditor(typeof(AnimationEventConfigSO))]
public class EventConfigEditor : UnityEditor.Editor
{
    private static readonly Dictionary<AnimationEventType, Color> _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.csid 字段,由 AnimationEventConfig 的 data 匹配)
    └── HurtBox.cs