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

513 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
{
/// <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/`
```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<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. 可取消帧窗口
攻击动画进入**取消窗口**后,玩家输入下一个动作可立即取消当前攻击动画,实现流畅连招或紧急规避:
```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<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