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

603 lines
22 KiB
Markdown
Raw Permalink 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.
# 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;
/// <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 事件注册模式
```csharp
// 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
```csharp
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
```csharp
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 之外的独立绑定)**
```csharp
// 在需要运行时动态替换 Config 的场合
AnimationEventBinder.Bind(_slashClip, _hardModeConfig, this);
```
---
## 8. 脚步系统
```csharp
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
```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<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
```