22 KiB
22 KiB
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
目录
- 模块职责
- AnimationEventType 枚举
- AnimationEventConfigSO
- Animancer 事件注册模式
- PlayerAnimationEvents
- EnemyAnimationEvents
- AnimationEventBinder
- 脚步系统
- 可取消帧窗口(Cancel Window)
- 编辑器工具: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 枚举
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
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 事件注册模式
// 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
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 key(IFeedbackPlayer.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
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 之外的独立绑定):
// 在需要运行时动态替换 Config 的场合
AnimationEventBinder.Bind(_slashClip, _hardModeConfig, this);
8. 脚步系统
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 key(11_AudioModule §2:PlaySFXAtPosition(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)
// 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 |
AnimationEventConfigSO 新增字段
// 路径: Assets/Scripts/Animation/AnimationEventConfigSO.cs(新增字段)
[HideInInspector] public float ExpectedClipLength = -1f; // 帧数;-1=未记录;Auto-detect 写入
EventConfigEditor 实现
// 路径: 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.cs(id 字段,由 AnimationEventConfig 的 data 匹配)
└── HurtBox.cs