513 lines
17 KiB
Markdown
513 lines
17 KiB
Markdown
# 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
|