603 lines
22 KiB
Markdown
603 lines
22 KiB
Markdown
# 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 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
|
||
|
||
```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 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)
|
||
|
||
```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.cs(id 字段,由 AnimationEventConfig 的 data 匹配)
|
||
└── HurtBox.cs
|
||
```
|