chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,512 @@
# 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