Files
zeling_v2/Docs/Design/20_AnimationEventSystem.md
2026-05-08 11:04:00 +08:00

17 KiB
Raw Permalink Blame History

20 · 动画事件系统Animation Event System

命名空间 BaseGames.Animation
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Combat · BaseGames.Feedback · BaseGames.Audio · Animancer Pro


目录

  1. 系统总览
  2. AnimancerEvent 注册规范
  3. 动画事件类型枚举
  4. AnimationEventConfigSO
  5. PlayerAnimationEvents — 玩家事件处理器
  6. EnemyAnimationEvents — 敌人事件处理器
  7. 脚步声系统
  8. 可取消帧窗口
  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 中清理:

// 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
  • 检测 SpawnProjectile payload 是否对应存在的 ProjectileConfigSO
  • 将问题输出到 Console双击跳转到对应 SO