Arpg第五节——方法
延迟激活碰撞体ColliderEnable
这段代码实现了一个可控制激活时间的碰撞体组件,主要功能是:
延迟激活 - 物体启用后,等待指定延迟时间才激活碰撞体
限时存活 - 碰撞体激活后只持续指定时间,然后自动关闭
状态管理 - 使用状态机管理碰撞体的生命周期
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class ColliderEnable : MonoBehaviour
{[Header("延迟多长时间激活")]public float delay; // 碰撞体激活前的延迟时间(秒)Collider _collider; // 引用挂载的碰撞体组件float enable_time; // 记录组件被启用时的时间戳[Header("存活时长")]public float duration; // 碰撞体激活后持续的时长(秒)byte state = 0; // 状态机:0-等待激活 1-等待关闭 2-已关闭private void Awake(){// 初始化时获取碰撞体组件引用_collider = this.GetComponent<Collider>();}private void OnEnable(){// 记录组件启用时的时间戳enable_time = GameTime.time;// 初始禁用碰撞体_collider.enabled = false;// 重置状态为等待激活state = 0;}void Update(){if (state == 0) // 等待激活状态{// 检查是否达到延迟激活时间if (GameTime.time - enable_time >= delay){// 激活碰撞体_collider.enabled = true;// 进入等待关闭状态state = 1;}}else if (state == 1) // 激活后的持续状态{// 检查是否达到总持续时间(延迟时间+持续时长)if (GameTime.time - enable_time >= delay + duration){// 禁用碰撞体_collider.enabled = false;// 进入已关闭状态state = 2;}}// state == 2 时不再进行任何操作}
}
技能编辑器SkillEditor
using System; // 基础.NET命名空间,包含核心类型(如String、Int32)和基础类
using System.Collections; // 包含非泛型集合(如ArrayList、Hashtable)
using System.Collections.Generic; // 包含泛型集合(如List<T>、Dictionary<TKey, TValue>)using UnityEditor; // Unity编辑器API命名空间,用于扩展编辑器功能(仅Editor模式下可用)using UnityEngine; // Unity引擎核心功能(如GameObject、Component、MonoBehaviour)
using UnityEngine.Playables; // Timeline播放系统相关功能
using UnityEngine.Timeline; // Timeline时间轴系统相关功能
using System.Linq; // LINQ(语言集成查询)功能,用于数据查询操作
1. 类定义和菜单项
public class SkillEditor : Editor
{[MenuItem("GameObject/CreateStateConfig")]public static void CreateStateConfig(){// 方法实现...}
}
继承自
Editor
类,表明这是一个 Unity 编辑器扩展添加了菜单项
GameObject/CreateStateConfig
,可以在 Unity 编辑器的 GameObject 菜单中调用
2. 获取选中的对象和 PlayableDirector
获取当前选中的 GameObject
检查该对象是否有
PlayableDirector
组件(用于控制 Timeline 播放)
//选择的物体 不是空var go = Selection.activeGameObject;if (go == null){return;}var director = go.GetComponent<PlayableDirector>();if (director == null){return;}
var go = Selection.activeGameObject;
获取当前在 Unity 编辑器中“被选中”的那个 GameObject(游戏对象)
Selection
是 Unity Editor 命名空间下的一个静态类(UnityEditor.Selection
),它代表用户在编辑器中当前的选择状态。activeGameObject
是Selection
类的一个属性,返回当前在 Hierarchy 或 Scene 视图中被选中的 GameObject。
- 如果没有选中任何 GameObject,它返回
null
。- 如果选中了多个 GameObject,它返回最后被点击/激活的那个(即“主选对象”)。
3. 分析 Timeline 轨道并判断轨道类型
初始化轨道字典,用于存储不同类型的轨道
遍历 Timeline 的所有输出轨道
识别并分类不同类型的轨道
/ 初始化动画总时长为 0
double anm_length = 0;// 创建一个字典,用于存储不同类型的轨道(TrackAsset)
// Key: 轨道类型标识(0=动作轨道,1=特效控制轨道,2=特效配置参数轨道)
// Value: 对应的 TrackAsset 对象
Dictionary<int, TrackAsset> track_dct = new Dictionary<int, TrackAsset>();//0动作 1特效 2特效配置参数
foreach (PlayableBinding item in director.playableAsset.outputs)
{// 分析不同类型的轨道
}
语法解析——遍历TimeLine输出结果
foreach (PlayableBinding item in director.playableAsset.outputs) {var track = item.sourceObject as TrackAsset; }
1、director
是什么?👉 它是一个
PlayableDirector
类型的组件(Component),通常挂载在某个 GameObject 上。
- 作用:控制 Timeline(时间轴)的播放、暂停、绑定对象等。
2、director.playableAsset是什么?
👉 它是
director
所关联的PlayableAsset
—— 通常是TimelineAsset
(你创建的那个 .playable 文件)。
- 作用:存储 Timeline 的结构,包括轨道(Track)、剪辑(Clip)、行为等。
- 类型:
PlayableAsset
是基类,实际运行时通常是TimelineAsset
。3、
director.playableAsset.outputs
👉 这是一个
IList<PlayableBinding>
类型的集合 —— 即“输出绑定列表”。
- 作用:描述 Timeline 中每个轨道(Track)如何与场景中的 GameObject 或组件进行绑定。
- 数量:通常与 Timeline 中的轨道数量一致(但不绝对,某些轨道可能没有输出绑定)。
- 内容:每个
PlayableBinding
对应一个轨道的“输出配置”。4、
PlayableBinding item
👉 每次循环中的
item
是一个PlayableBinding
结构体(struct),它包含以下重要字段:
字段名 类型 说明 sourceObject
UnityEngine.Object
通常指向 Timeline 中的 TrackAsset
(轨道)streamName
string
输出流的名称(通常和轨道名一致) sourceOutputType
Type
输出数据的类型,如 typeof(AnimationPlayableOutput)
outputTarget
Object
绑定的目标对象(如 Animator、GameObject 等,可能为 null) 总结:PlayableBinding 是 item 的类型,director.playableAsset.outputs即是该TimeLine中的输出结果——这段语法就是遍历该输出结果。
例如:
执行代码如下:
if (director?.playableAsset?.outputs != null) {foreach (PlayableBinding binding in director.playableAsset.outputs){// 获取轨道if (binding.sourceObject is TrackAsset track){Debug.Log($"轨道类型: {track.GetType().Name}, 名称: {track.name}");// 获取绑定目标var target = binding.outputTarget;if (target != null){Debug.Log($"绑定到: {target.name}");// 根据轨道类型进行不同处理if (track is AnimationTrack animationTrack){Debug.Log("这是一个动画轨道");var animator = target as Animator;if (animator != null){Debug.Log($"绑定到: {animator.gameObject.name} 的 Animator");}}else if (track is ControlTrack controlTrack){Debug.Log("这是一个控制轨道");}else if (track is ActivationTrack activationTrack){Debug.Log("这是一个激活轨道");var gameObject = target as GameObject;if (gameObject != null){Debug.Log($"绑定到: {gameObject.name}");}}}}} }
输出结果:
轨道类型: AnimationTrack, 名称: Animation Track 绑定到: Player 这是一个动画轨道 绑定到: Player 的 Animator轨道类型: ControlTrack, 名称: Control Track 绑定到: null 这是一个控制轨道轨道类型: ActivationTrack, 名称: Activation Track 绑定到: Enemy 这是一个激活轨道 绑定到: Enemy
语法解析——安全类型转换
TrackAsset track = item.sourceObject as TrackAsset;
item.sourceObject as TrackAsset
:表达式,执行“安全类型转换”流程作用:
- 获取
item.sourceObject
→ 得到一个UnityEngine.Object
类型的对象。- 尝试将其转换为
TrackAsset
:
- ✅ 如果它确实是
TrackAsset
或其子类(如AnimationTrack
)→ 返回该对象。- ❌ 如果不是(比如是
null
或其他类型)→ 返回null
。- 将结果赋值给变量
track
。track
的类型是TrackAsset
(或null
)。为什么要转换类型:这里可能是TimeLine输出结果的容器只能是sourceObject,不能区分出具体的Track类型,所以才要转换成TrackAsset用于分类
UnityEngine.Object└── ScriptableObject└── TrackAsset├── AnimationTrack├── ActivationTrack├── ControlTrack
代码解析——判断轨道类型并存储
// 遍历 Timeline Director 所关联的 PlayableAsset 的所有输出绑定(即所有轨道)
foreach (PlayableBinding item in director.playableAsset.outputs)
{// 尝试将当前绑定的 sourceObject 转换为 TrackAsset(Timeline 中的轨道基类)var track = item.sourceObject as TrackAsset;// 如果转换成功(即确实是轨道对象)if (track != null){// 情况1:如果是动画轨道(AnimationTrack)if (track is AnimationTrack _AnimationTrack){// 确保只记录第一个动画轨道(避免重复赋值)if (track_dct.ContainsKey(0) == false){// 打印动画轨道名称(调试用)Debug.Log(_AnimationTrack.name + " " + track.name);// 将该动画轨道存入字典,键为 0track_dct[0] = track;// 获取该轨道上的所有 TimelineClip(通常是动画片段)TimelineClip[] clip = track.GetClips() as TimelineClip[];// 计算第一个 Clip 的持续时间(结束时间 - 开始时间),作为整个动作的参考时长 anm_length = clip[0].end - clip[0].start;// 打印计算出的动作时长(调试用)Debug.Log("动作时长:" + anm_length);}}// 情况2:如果是控制轨道(ControlTrack,通常用于激活/控制 GameObject,比如特效)else if (track is ControlTrack _ControlTrack){// 确保只记录第一个控制轨道if (track_dct.ContainsKey(1) == false){// 打印控制轨道名称(调试用)Debug.Log(_ControlTrack.name + " " + track.name);// 将该控制轨道存入字典,键为 1track_dct[1] = track;}}// 情况3:如果是特效数据配置轨道,存入字典,键为2else if (track is EffectTrackAsset _EffectTrackAsset){if (track_dct.ContainsKey(2) == false){track_dct[2] = track;}}// 情况4:如果是位移配置轨道,存入字典,键为3else if (track is MoveTrackAsset _MoveTrackAsset){if (track_dct.ContainsKey(3) == false){track_dct[3] = track;}}}
}
// 获取该轨道上的所有 TimelineClip(通常是动画片段) TimelineClip[] clip = track.GetClips() as TimelineClip[]; // 计算第一个 Clip 的持续时间(结束时间 - 开始时间),作为整个动作的参考时长 nm_length = clip[0].end - clip[0].start;
track.GetClips()
调用track
对象的GetClips()
方法,该方法通常返回一个IEnumerable<TimelineClip>
(可枚举的动画片段集合)。
as TimelineClip[]
使用as
操作符尝试将返回值强制转换为TimelineClip[]
(即 TimelineClip 数组)两种变量的参数都是关于动画片段
- TimelineClip[]:适用于需要快速随机访问元素的情况,但需要预先知道元素数量并占用更多内存。
- IEnumerable<TimelineClip>:适用于需要遍历大量元素的情况,更加灵活和高效,但不支持直接通过索引访问元素
TimelineClip和TrackAsset的区别
Q1:TimelineClip[ ]和TrackAsset都包含了Skin (Animator) 轨道,这两者变量区别是什么
A1:
TrackAsset
—— 代表“轨道”本身
TrackAsset
是 Unity Timeline 系统中的一个类,表示 Timeline 时间轴上的一个轨道(Track)。
例如你图中的:
- “Skin (Animator)” 轨道
- “Control Track” 轨道
- “4002Atk1” 轨道
每一个都是一个
TrackAsset
实例(或其子类,如AnimationTrack
、ActivationTrack
等)。它能做什么?
- 存储轨道的设置(如轨道名称、绑定对象、混合模式等)
TimelineClip[]
—— 代表“轨道上的动画片段数组”
TimelineClip[]
是一个数组,存储某个轨道上的所有动画片段(Clips)对象。例如你图中 “Skin (Animator)” 轨道上有一个片段叫
attack_01
—— 它就是一个TimelineClip
。
TimelineClip[]
就是这些片段的集合(数组形式)。它能做什么?
- 访问每个片段的属性:
.start
,.end
,.duration
,.displayName
,.asset
(关联的动画资源)等图文对比
项目 TrackAsset
TimelineClip[]
代表什么 一条轨道(如 Skin (Animator)) 该轨道上的多个动画片段组成的数组 层级关系 父级容器 子级内容(片段集合) 功能 管理轨道设置、获取/操作片段集合 操作每个片段的时间、资源、属性等 获取方式 timelineAsset.GetTrack(index)
或通过路径查找track.GetClips().ToArray()
是否包含轨道? ✅ 是轨道本身 ❌ 不包含轨道,只是轨道上的片段集合 TrackAsset
是轨道本身,TimelineClip[]
是这条轨道上所有动画片段的集合 —— 前者是“容器”,后者是“内容”。
4. 提取特效信息
从特效轨道提取所有剪辑
计算每个特效相对于动画时长的触发时间点
创建特效配置对象
将特效对象保存为预制体
// 声明一个字典,用于存储技能特效配置项,键为资源路径,值为特效配置对象
Dictionary<string, SkillEditor_EffectItemConfig> skill_dct = new Dictionary<string, SkillEditor_EffectItemConfig>();// 【特效轨道处理】尝试从轨道字典中获取索引为1的轨道(通常代表特效轨道)
if (track_dct.TryGetValue(1, out var effect))
{// 获取该轨道上所有的动画片段(clips为TimelineClip数组)var clips = effect.GetClips() as TimelineClip[];// 遍历每一个剪辑foreach (var item in clips){// 计算该剪辑的触发时间比例(触发点就是百分比类型数据)var n = item.start / anm_length;// 创建一个新的技能特效配置对象SkillEditor_EffectItemConfig c = new SkillEditor_EffectItemConfig();// 将剪辑关联的资产转换为ControlPlayableAsset类型(用于控制GameObject的播放资产)ControlPlayableAsset x = item.asset as ControlPlayableAsset;// 通过PlayableDirector的解析器,解析出该资产关联的实际场景中的GameObjectvar o = x.sourceGameObject.Resolve(director.playableGraph.GetResolver());// 设置配置对象的obj字段:// 如果该对象没有父物体,则使用自身;否则使用其父物体(通常是为了获取完整的特效根节点)c.obj = o.transform.parent == null ? o : o.transform.parent.gameObject;// 设置资源路径,格式为 "Effect/对象名"c.res_path = $"Effect/{c.obj.name}";// 设置触发时间比例(0~1之间的值,表示在动画中的相对触发时刻)c.trigger = n;// 将该配置项添加到字典中,以资源路径为键skill_dct[c.res_path] = c;// 在控制台输出日志,显示特效生成点的时间比例和对象名称Debug.Log($"特效生成点:{n} {c.obj.name}");// 标记是否需要创建Prefab,默认为truebool create_prefab = true;// 检查当前GameObject是否已经是Prefab的实例(避免重复创建)if (PrefabUtility.GetPrefabInstanceStatus(c.obj) != PrefabInstanceStatus.NotAPrefab){// 如果是Prefab实例,则跳过创建create_prefab = false;}// 定义Prefab保存路径(位于Assets/Resources/Effect/目录下)string prefabPath = "Assets/Resources/Effect/" + c.obj.name + ".prefab";// 如果需要创建Prefab,且该路径下尚不存在同名Prefab资源if (create_prefab && AssetDatabase.LoadAssetAtPath(prefabPath, typeof(GameObject)) == null){// 将当前GameObject保存为Prefab资源,并自动建立连接(保持场景中对象与Prefab的关联)GameObject prefab = PrefabUtility.SaveAsPrefabAssetAndConnect(c.obj, prefabPath, InteractionMode.AutomatedAction);// 注意:返回的prefab对象可以用于后续处理(如记录、验证等),但当前未使用}}
}
语法解析——ControlPlayableAsset类型
// 将剪辑关联的资产转换为ControlPlayableAsset类型(用于控制GameObject的播放资产)
ControlPlayableAsset x = item.asset as ControlPlayableAsset;
ControlPlayPlayableAsset
是 Unity Timeline 系统中的一个核心组件,它允许你在时间轴的一条轨道上直接控制另一个游戏对象(GameObject)的激活状态、播放其动画以及触发事件。
Control Track 和 ControlPlayableAsset 的关系
Control Track:
- 是 Timeline 中的一种轨道类型。
- 用于放置和管理
ControlPlayableAsset
类型的剪辑(Clips)。- 可以用来控制场景中的 GameObject 在特定时间点的行为变化。
ControlPlayableAsset:
- 是一种特殊的 Playable Asset,专门用于控制 GameObject 的行为。
- 它包含一个
ExposedReference<GameObject>
字段,允许你在 Inspector 中指定要控制的 GameObject。- 提供了多种控制模式,如克隆(Clone)、激活(Activate)、禁用(Deactivate)等。
与TimelineClip和TrackAsset的区别 的性质相似——Track是容器,Asset是内容。
另外,在这里ControlPlayableAsset是用于控制特效/碰撞器生成
语法解析——解析关联资产
这行代码的核心目的是:将一个在编辑时设置的、间接的引用(ExposedReference<GameObject>
)转换(解析)为运行时实际的、可用的 GameObject 对象(GameObject
)。
ControlPlayableAsset x = item.asset as ControlPlayableAsset;// 通过PlayableDirector的解析器,解析出该资产关联的实际场景中的GameObject
var o = x.sourceGameObject.Resolve(director.playableGraph.GetResolver());
1、
x.sourceGameObject
的类型它不是我们常见的
public GameObject类型,而是
ExposedReference<GameObject>类型
,间接引用物体的变量类型
public struct ExposedReference<T> where T : Object {public T Resolve(IExposedPropertyTable resolver);// 内部其实存的是一个 GUID 或路径字符串 }
这里涉及到资源资产(Asset)和场景中资产(Scene)的引用关系:即使在同一个场景中,如果你删除了被引用的 GameObject,然后创建一个新的、名字相同的 GameObject,引用也会丢失——这就是
间接引用的来源
2、
director.playableGraph.GetResolver()
这个函数一个“解析器”,知道怎么根据“引用描述”找到真实 GameObject
director
: 你的PlayableDirector
组件引用。
.playableGraph
: 每个PlayableDirector
在播放时都会管理一个PlayableGraph
。这是 Unity Playables API 的核心,它负责组织所有播放节点(Playable)及其关系。
.GetResolver()
: 这是关键!PlayableGraph
继承并持有了那个作为“柜台”的IExposedPropertyTable
解析器。
这个解析器就是在 Unity 编辑器中你设置那个
PlayableDirector
时,由 Unity 自动创建和维护的映射表3、.Resolve(...)
这就是执行具体的解析动作了。
🟢 第1层 x
ControlPlayableAsset
Timeline 剪辑关联的资产对象(控制型资产) 🟡 第2层 x.sourceGameObject
ExposedReference<GameObject>
一个“可暴露的引用”,不是 GameObject 本身,而是“引用描述” 🔵 第3层 director.playableGraph.GetResolver()
IExposedPropertyTable
一个“解析器”,知道怎么根据“引用描述”找到真实 GameObject 🟣 第4层 .Resolve(...)
方法调用 执行“解析”动作,返回真实的 GameObject 🟥 最终 var o = ...
GameObject
解析出来的实际游戏对象,可以拿来用(比如 Instantiate、GetParent、改名等
代码解析——物体信息存储到结构体类中
// 设置配置对象的obj字段(储存在SkillEditor_EffectItemConfig实例中):// 如果该对象没有父物体,则使用自身;否则使用其父物体(通常是为了获取完整的特效根节点)c.obj = o.transform.parent == null ? o : o.transform.parent.gameObject;// 设置资源路径,格式为 "Effect/对象名"c.res_path = $"Effect/{c.obj.name}";// 设置触发时间比例(0~1之间的值,表示在动画中的相对触发时刻)c.trigger = n;// 将该配置项添加到字典中,以资源路径为键skill_dct[c.res_path] = c;// 在控制台输出日志,显示特效生成点的时间比例和对象名称Debug.Log($"特效生成点:{n} {c.obj.name}");
额外注意一下,这里只是存储资源路径——下段内容中的prefabPath才是用来将该对象物体存入Resource/Effect的文件夹中,别搞错了。
语法解析——选中物体创建对应预制体放入Resource文件夹
// 声明一个布尔变量,用于控制是否创建Prefab,默认为true(即默认要创建)
bool create_prefab = true;// 检查当前GameObject是否已经是某个Prefab的实例
if (PrefabUtility.GetPrefabInstanceStatus(c.obj) != PrefabInstanceStatus.NotAPrefab)
{// 如果不是 "NotAPrefab"(即它是Prefab实例、断开连接的实例等),则不创建新的Prefabcreate_prefab = false;
}// 定义要保存Prefab的路径(在Assets/Resources/Effect/目录下,以对象名称命名.prefab文件)
string prefabPath = "Assets/Resources/Effect/" + c.obj.name + ".prefab";// 只有在满足两个条件时才创建Prefab:
// 1. create_prefab 为 true(即当前对象不是Prefab实例)
// 2. 该路径下尚不存在同名Prefab资源(避免重复创建或覆盖)
if (create_prefab && AssetDatabase.LoadAssetAtPath(prefabPath, typeof(GameObject)) == null)
{// 创建Prefab资产并自动将原GameObject与新Prefab建立连接(即变成Prefab实例)GameObject prefab = PrefabUtility.SaveAsPrefabAssetAndConnect(c.obj, prefabPath, InteractionMode.AutomatedAction);
}
if (PrefabUtility.GetPrefabInstanceStatus(c.obj) != PrefabInstanceStatus.NotAPrefab)
1.
PrefabUtility.GetPrefabInstanceStatus(c.obj)
- 作用:调用 Unity 编辑器 API,获取 GameObject
c.obj
的 Prefab 实例状态。- 返回值类型:
PrefabInstanceStatus
(枚举类型)- 常见值:
NotAPrefab
— 不是 Prefab 实例Connected
— 是已连接的 Prefab 实例Disconnected
— 是断开连接的 Prefab 实例MissingAsset
— Prefab 资源丢失这个判断条件就是当前对象如果不为实例
AssetDatabase.LoadAssetAtPath(prefabPath, typeof(GameObject)) == null
AssetDatabase.LoadAssetAtPath(...)
这是 Unity Editor API 中的一个静态方法调用。
- 所属类:
AssetDatabase
(UnityEditor 命名空间)- 作用:从项目 Assets 文件夹中的指定路径加载资源(Asset)
typeof(GameObject)
- 作用:C# 中的运算符,用于在编译时获取某个类型的
System.Type
对象- 返回值:
Type
类型,表示GameObject
类的元数据- 用途:告诉
LoadAssetAtPath
方法:“我要加载的是 GameObject 类型的资源”可以理解为从prefabPath加载GameObject类型的资源
PrefabUtility.SaveAsPrefabAssetAndConnect(c.obj, prefabPath, InteractionMode.AutomatedAction);
将场景中的 GameObject
c.obj
保存为一个 新的 Prefab 资产(保存到prefabPath
路径),并自动将原 GameObject 替换为该 Prefab 的实例public static GameObject SaveAsPrefabAssetAndConnect(GameObject instanceGameObject, // 要保存为Prefab的源GameObject(通常是场景中的对象)string assetPath, // Prefab 保存的路径(必须是 Assets/ 开头的完整路径)InteractionMode interactionMode // 交互模式:决定是否弹窗、是否撤销支持等 );
5. 处理特效配置轨道
将EffectPlayableAsset中的配置参数导入到对应状态面板(StateScriableObject)Effectconfig中
//收集特效Effect配置轨道 ==> 转化为需求参数 (插入到stateconfig_effectconfig)if (track_dct.TryGetValue(2, out var effect_config_track)){//获取当前状态TimeLine全部Clip片段var clips = effect_config_track.GetClips() as TimelineClip[];for (int i = 0; i < clips.Length; i++){var item = clips[i];// 尝试将 Clip 关联的资源(asset)转换为 EffectPlayableAsset 类型(自定义的可播放特效资产)var effectPlayableAsset = item.asset as EffectPlayableAsset;if (effectPlayableAsset != null)//如果转换成功{//从技能配置字典 skill_dct 中,按索引 i 获取对应的技能配置(但似乎并不推荐这种方式)//将技能配置中的资源路径赋值给特效资产的配置//将配置好的特效参数反向赋值回技能配置对象,供后续使用var skill_config = skill_dct.ElementAt(i).Value;effectPlayableAsset.Config.res_path = skill_config.res_path;effectPlayableAsset.Config.trigger = (float)Math.Round(skill_config.trigger, 3);skill_config.effectConfig = effectPlayableAsset.Config; }}}
6. 处理位移轨道
if (track_dct.TryGetValue(3, out var move))
{var clips = move.GetClips() as TimelineClip[];foreach (var item in clips){// 计算位移触发时间和持续时间var n = item.start / anm_length;var end = (float)Math.Round((item.end / anm_length), 3);// 创建位移配置move_config.Add(x.Config);}
}
7. 加载和更新状态配置
string unit_id = director.transform.parent.gameObject.name;
int state_id = int.Parse(director.transform.gameObject.name.Split('@')[0]);
红色部分unit_id,紫色部分state_id
1、遍历到对应的状态id表(如1005)
// 从资源中加载指定单位的状态配置文件(ScriptableObject),路径为 "StateConfig/{unit_id}"
var anmConfig = Resources.Load<StateScriptableObject>($"StateConfig/{unit_id}");// 声明一个列表,用于存储当前要处理的目标状态所关联的特效配置(EffectConfig)
List<EffectConfig> target = null; // 初始化为空,后续会指向匹配状态的 effectConfigs// 遍历该单位所有状态配置,查找与当前 state_id 匹配的状态
for (int i = 0; i < anmConfig.states.Count; i++)
{// 如果找到匹配的状态ID,则将该状态下的特效配置列表赋值给 targetif (state_id == anmConfig.states[i].id){target = anmConfig.states[i].effectConfigs;break; // 找到后可提前退出循环(可选优化,非必需)}
}
2、找到表后清理无效的表
target是当前状态1005的effectConfigs数量
Dictionary<string, SkillEditor_EffectItemConfig> skill_dct 是全局自定义类字典,用于收集并关联来自不同轨道的数据
键(Key): string 类型,存储的是特效的资源路径(如:"Effect/FireExplosion") 值(Value): SkillEditor_EffectItemConfig 类型,是一个自定义类,存储了某个特效的所有配置信息
// 来自特效轨道(ControlTrack)的处理 skill_dct[c.res_path] = c; // 存入资源路径和基础信息// 来自特效配置轨道的处理 var skill_config = skill_dct.ElementAt(i).Value; // 通过索引或路径找到对应的条目 skill_config.effectConfig = effectPlayableAsset.Config; // 补充详细配置
// 如果找到了对应的状态配置(target 不为空),则开始清理无效或过时的配置项
if (target != null)
{// 从后往前遍历 target 列表(避免在遍历时因删除元素导致索引错乱)for (int i = target.Count - 1; i >= 0; i--){// 如果当前配置的资源路径为空,或者不在当前配置字典 skill_dct 中(即已失效或不再需要)if (string.IsNullOrEmpty(target[i].res_path) || skill_dct.ContainsKey(target[i].res_path) == false){target.Remove(target[i]); // 从列表中移除该无效配置}}
}
3、添加临时列表,确认需要是更新还是新增列表(特效配置表)
// 准备一个临时列表,用于存放需要新增的特效配置
List<EffectConfig> wait_add = new List<EffectConfig>();// 遍历当前技能字典 skill_dct(Key: 资源路径, Value: 包含 EffectConfig 的结构)
foreach (var item in skill_dct)
{bool add = true; // 标记是否需要新增该配置,默认为 true// 遍历当前 target 列表,检查是否已存在相同资源路径的配置for (int i = 0; i < target.Count; i++){// 如果已存在相同 res_path 的配置,则更新其内容if (target[i].res_path == item.Key){target[i] = item.Value.effectConfig; // 用新配置覆盖旧配置add = false; // 标记为“无需新增”break; // 找到匹配项后可跳出内层循环——因为将特效表配置到配置面板上需要跳出循环}}// 如果未找到匹配项(add 仍为 true),则将该配置加入待添加列表if (add){wait_add.Add(item.Value.effectConfig);}
}// 如果有待添加的新配置,则一次性添加到目标列表中
if (wait_add.Count > 0)
{target.AddRange(wait_add.ToArray()); // 使用 ToArray() 是为了兼容某些旧版 List 实现(非必需,可直接 AddRange(wait_add))
}
这里设计wait_add的目的:避免在遍历
target
列表的同时修改它
- 但 逻辑上是危险的 —— 因为下一次循环中,
target.Count
会变大,如果你在内层for
循环中依赖target.Count
,就可能出错(虽然你这里没有)。foreach (var item in skill_dct) {bool add = true;for (int i = 0; i < target.Count; i++){………………}if (add){// ❌ 如果这里直接写:target.Add(item.Value.effectConfig);// 会导致什么问题?} }
为什么需要在这里
break
?假设
target
有 1000 个元素,而你在第 3 个就找到了匹配项:
- 如果 不加
break
→ 你还会继续从第 4 个遍历到第 1000 个,做 997 次无意义的比较。- 如果 加了
break
→ 立即跳出,节省 997 次判断。
4、将外部配置好的列表传递给该状态
在状态配置中找到指定
state_id
对应的状态(如1005),然后用新的target
(特效配置)和move_config
(物理运动配置)更新它。
// 遍历当前单位的所有状态配置(anmConfig.states),寻找与指定 state_id 匹配的状态
for (int i = 0; i < anmConfig.states.Count; i++)
{// 如果当前状态的 ID 与目标 state_id 匹配if (state_id == anmConfig.states[i].id){// 将外部处理好的特效配置列表(target)赋值给该状态的 effectConfigs,完成更新anmConfig.states[i].effectConfigs = target;// 清空该状态原有的物理运动配置(如位移、速度、转向等)anmConfig.states[i].physicsConfig.Clear();// 如果有新的运动配置(move_config)需要应用,则将其全部添加到物理配置列表中if (move_config.Count > 0){anmConfig.states[i].physicsConfig.AddRange(move_config);}// 可选:找到匹配项后可 break,避免不必要的后续遍历(性能优化)// break;}
}
SkillEditor_EffectItemConfig
class SkillEditor_EffectItemConfig
{public GameObject obj;//表示这个效果项关联的 游戏对象(GameObject)。public double trigger;//表示触发该效果的 时机或条件public string res_path;//表示该效果资源的 资源路径public EffectConfig effectConfig;//表示该效果的 详细配置数据,是一个自定义结构或类 EffectConfig}
EffectPlayableAsset
EffectPlayableAsset
是一个 Timeline Clip 的“数据模板”,它:
- 存储了播放“特效”所需的配置数据(如持续时间、特效预制体、参数等)。
- 在 Timeline 播放时,被用来创建一个实际的“可播放行为”(
EffectBehaviour
),该行为控制特效如何播放。- 支持在 Timeline 编辑器中像普通 Clip 一样拖拽、剪辑、排列。
PlayableAsset
:表示这是一个 Timeline 可播放资源的“资产模板”。
ITimelineClipAsset
:实现这个接口后,该 Asset 就能被 Timeline 当作 Clip 使用(可拖入轨道)。
EffectBehaviour
应该是一个继承自PlayableBehaviour
的类,用于定义播放时每帧执行的逻辑(如播放特效、修改参数等)。
_EffectBehaviour
是作为“模板”存在的,真正的播放行为由每次CreatePlayable
时创建的克隆体执行,避免多个 Clip 共用同一个实例导致数据污染。EffectConfig
是你自定义的数据结构,用于配置该特效播放的各种参数(如持续时间、粒子系统、音效等)。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables; // 用于创建可播放内容(Playable)
using UnityEngine.Timeline; // 用于与 Timeline 系统集成// 标记该类可序列化,使其能在 Inspector 中显示并保存数据
[System.Serializable]
// 继承 PlayableAsset:表示这是一个 Timeline 中可播放的资源
// 实现 ITimelineClipAsset 接口:使该 Asset 能作为 Timeline Clip 使用
public class EffectPlayableAsset : PlayableAsset, ITimelineClipAsset
{// 私有字段,用于存储默认的 EffectBehaviour 实例(模板)// 注意:这个实例不会直接用于播放,而是作为“原型”在 CreatePlayable 时被克隆private readonly EffectBehaviour _EffectBehaviour = new EffectBehaviour();// 用于存储该 Clip 的配置数据,由用户在 Timeline 编辑器中设置public EffectConfig Config;// 重写 CreatePlayable 方法:Timeline 在播放时会调用此方法创建实际的 Playable 实例public override Playable CreatePlayable(PlayableGraph graph, GameObject owner){// 创建一个 ScriptPlayable<EffectBehaviour> 实例// 它包装了 _EffectBehaviour 的一个“副本”(实际上是深拷贝行为数据)var b = ScriptPlayable<EffectBehaviour>.Create(graph, _EffectBehaviour);// 获取该 Playable 实例内部的行为对象(即 EffectBehaviour 的克隆体)var clone = b.GetBehaviour();// 将配置数据赋值给克隆体,确保播放时使用的是当前 Clip 的配置clone.Config = Config;// 注释掉的代码:原本可能用于绑定某个 Transform(如结束位置)// clone.EndTransform = endTrans.Resolve(graph.GetResolver());// 返回创建好的 Playable,Timeline 系统将用它来播放return b;}// 实现 ITimelineClipAsset.clipCaps 属性// ClipCaps.None 表示该 Clip 不支持混合、循环等高级功能(保持默认行为)// 可根据需要改为 ClipCaps.Blending、ClipCaps.Looping 等public ClipCaps clipCaps => ClipCaps.None;
}
1、私有模板行为
private readonly EffectBehaviour _EffectBehaviour = new EffectBehaviour();
- 这是一个“原型”或“模板”行为实例。
- 不会直接用于播放!仅用于在
CreatePlayable
时被“克隆”。- Unity 的
ScriptPlayable<T>.Create(graph, template)
会复制template
的字段值(浅拷贝),创建一个新的行为实例供播放使用。- 使用
readonly
是为了防止意外修改这个模板。
2、创建实际播放实例
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
这是 Timeline 播放系统在每次需要播放这个 Clip 时调用的方法。
➤ 创建 Playable 行为实例:
var b = ScriptPlayable<EffectBehaviour>.Create(graph, _EffectBehaviour);
ScriptPlayable<T>.Create(graph, template)1.ScriptPlayable<T> 是 Unity 提供的一个泛型结构体,用于创建和包装一个自定义的 PlayableBehaviour(即你的 EffectBehaviour) ————表示这个 ScriptPlayable 将包装并执行 EffectBehaviour 类型的逻辑2.参数 (graph, _EffectBehaviour) graph (类型: PlayableGraph) 这是你希望将新创建的 Playable 节点添加到的 播放图。 所有 Timeline 轨道和 Clip 的 Playable 节点最终都会被添加到同一个 PlayableGraph 中,由它来统一调度和执行。_EffectBehaviour (类型: EffectBehaviour) 这是你的 行为模板 (template) 或 原型 (prototype)。 Create 方法会以这个 _EffectBehaviour 实例为蓝本,拷贝其字段的值(浅拷贝),来初始化新创建的 EffectBehaviour 实例。可以把它理解为:告诉工厂,“用这个模板(_EffectBehaviour)生产一个节点,并把它放到这个舞台(graph)上”
- 基于
_EffectBehaviour
模板,创建一个新的ScriptPlayable<EffectBehaviour>
实例。- 这个实例是 Timeline 播放图(PlayableGraph)中的一个节点,负责实际播放逻辑。
- Timeline 本身不直接执行逻辑,它只是一个“调度器”。
- Timeline 通过 PlayableGraph 来管理所有 Clip 的播放顺序、混合、同步等,而每个 Clip 的“实际行为”必须由一个 Playable 节点 来实现
Timeline相关参数:
【Unity Blog】第二十五 · Timeline 结构及其源码浅析_unity timeline-CSDN博客
Q1:为什么不能直接用_EffectBehaviour
模板?
Timeline 是多实例系统
同一个EffectPlayableAsset
可能被用在 Timeline 的多个地方,甚至多个 Timeline 同时播放。
→ 如果所有播放都共享同一个_EffectBehaviour
实例,会导致状态冲突(比如一个播放完了把另一个的状态也清空了)。PlayableGraph 要求每个节点是独立的
Unity 的播放系统需要每个 Clip 在播放图中拥有自己独立的状态(播放时间、暂停状态、混合权重等),所以必须为每个 Clip 创建独立的 Playable 实例。
➤ 获取行为对象并注入配置:
var clone = b.GetBehaviour(); clone.Config = Config;
GetBehaviour()
获取 Playable 内部封装的EffectBehaviour
实例(即“克隆体”)。
- 所以你需要通过
b.GetBehaviour()
来获取这个“真正的行为对象”。- 把当前 Clip 的配置
Config
赋值给它 → 确保播放时使用的是这个 Clip 的专属设置。举例说明——b.GetBehaviour();
假设你的
EffectBehaviour
是这样:public class EffectBehaviour : PlayableBehaviour {public string EffectName; // 比如 “爆炸”、“闪光”public float Duration; }
而你的模板是:
private readonly EffectBehaviour _EffectBehaviour = new EffectBehaviour() {EffectName = "flash",Duration = 1.0f };
当你调用:
var b = ScriptPlayable<EffectBehaviour>.Create(graph, _EffectBehaviour); var clone = b.GetBehaviour(); clone.EffectName = "Big Explosion!";//给克隆出来的模板重新赋值 clone.Duration = 3.0f;
这里的机制是因为特效轨道可以复用——所以使用模板进行克隆并进行赋值
➤ 返回 Playable:
return b;
- 把创建好的 Playable 返回给 Timeline 系统,Timeline 会负责调度它在正确的时间播放
3、固定播放能力
public ClipCaps clipCaps => ClipCaps.None;
ClipCaps
是 Unity Timeline 系统中的一个 枚举(Enum),用于描述一个 Timeline Clip 支持哪些“高级播放能力”。[Flags] public enum ClipCaps {None = 0, // 不支持任何特殊能力Blending = 1 << 0, // 支持混合(淡入淡出、权重混合)Looping = 1 << 1, // 支持循环播放// 可能还有其他... }
public ClipCaps clipCaps {get { return ClipCaps.None; } }
这意味着:我这个 Clip 不支持任何高级功能 —— 不能混合、不能循环,就老老实实从头播到尾
EffectTrackAsset
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Timeline;[TrackColor(0.855f, 0.8623f, 0.87f)]
[TrackClipType(typeof(EffectPlayableAsset))]
public class EffectTrackAsset : TrackAsset
{}
[TrackColor(...)]
- 设置该轨道在 Timeline 编辑器中的显示颜色。
- 参数是 RGB 颜色值(范围 0~1),这里是浅灰色调:
(0.855f, 0.8623f, 0.87f)
[TrackClipType(typeof(EffectPlayableAsset))]
- 指定该轨道只能添加某种类型的 Clip。
EffectPlayableAsset
是你(或团队)自定义的 Playable Asset 类型,用于控制某种特效(如粒子、屏幕特效、音效等)。
public class EffectTrackAsset : TrackAsset
- 继承自
TrackAsset
,这是 Unity Timeline 中所有轨道的基础类。- 表示这是一个可以在 Timeline 上创建和使用的轨道资产。
EffectBehaviour
public class EffectBehaviour : PlayableBehaviour
{public EffectConfig Config;public override void OnBehaviourPause(Playable playable, FrameData info){base.OnBehaviourPause(playable, info);}public override void OnBehaviourPlay(Playable playable, FrameData info){base.OnBehaviourPlay(playable, info);}public override void OnGraphStart(Playable playable){base.OnGraphStart(playable);}public override void OnGraphStop(Playable playable){base.OnGraphStop(playable);}public override void OnPlayableCreate(Playable playable){base.OnPlayableCreate(playable);}public override void OnPlayableDestroy(Playable playable){base.OnPlayableDestroy(playable);}public override void PrepareData(Playable playable, FrameData info){base.PrepareData(playable, info);}public override void PrepareFrame(Playable playable, FrameData info){base.PrepareFrame(playable, info);}public override void ProcessFrame(Playable playable, FrameData info, object playerData){base.ProcessFrame(playable, info, playerData);//每一帧 处理//按移动的类型 让对应的特效 进行移动//检测它是否命中目标//Debug.LogError($"帧变动..:{playable.GetTime()}");//if (Config != null && Config.res_path != null) {// var go = GameObject.Find(Config.res_path);//}}
}
该脚本类的核心方法集合如下:
通过继承 PlayableBehaviour,你可以重写以下关键方法,精确控制特效在 Timeline 中的行为:OnGraphStart // 整个 Graph 开始时调用(全局)
OnGraphStop // 整个 Graph 停止时调用
OnPlayableCreate // Playable 节点被创建时
OnPlayableDestroy// Playable 节点被销毁时
OnBehaviourPlay // 该 Clip 开始播放时
OnBehaviourPause // 该 Clip 被暂停时
PrepareFrame // 每帧准备阶段
ProcessFrame // 每帧处理阶段(最常用)
方法名 调用者 调用时机说明 OnPlayableCreate
PlayableGraph 当 Graph 创建该 Playable 节点时调用(通常在 Timeline 开始播放前) OnGraphStart
PlayableGraph 整个 PlayableGraph 开始播放时调用(全局一次,所有节点都会收到) OnBehaviourPlay
Playable 系统 当前 Clip(对应这个 Behaviour)开始播放时调用(比如 Timeline 播放到这一轨) PrepareFrame
Playable 系统(每帧) 在每一帧的“准备阶段”调用,适合做数据准备、状态同步 ProcessFrame
Playable 系统(每帧) 在每一帧的“处理阶段”调用,适合执行主要逻辑(如移动、检测、播放特效等) OnBehaviourPause
Playable 系统 当前 Clip 被暂停或跳过时调用(例如 Timeline 暂停、跳转、Clip 结束) OnGraphStop
PlayableGraph 整个 Graph 停止播放时调用(全局一次) OnPlayableDestroy
PlayableGraph Playable 节点被销毁时调用(资源清理的好地方
EffectBehaviour与EffectPlayableAsset共同作用
你的设计中:
EffectBehaviour
负责运行时行为逻辑(每帧做什么);EffectPlayableAsset
负责编辑器配置和实例化(把配置数据传给 Behaviour);这是一种经典的“数据驱动行为”模式:
var clone = b.GetBehaviour(); clone.Config = Config; // 将配置数据注入运行时行为
为什么继承 PlayableBehaviour 是“正确做法”
继承PlayableBehaviour
不是“有什么优势”,而是“必须这么做” —— 它是接入 Unity Timeline/Playable 系统的唯一标准方式。
优势点 说明 符合系统架构 Unity Playable 系统强制要求,否则无法使用 ScriptPlayable 生命周期管理 自动获得播放、暂停、帧更新等回调,精准控制行为 数据行为分离 Asset 配置 + Behaviour 执行,结构清晰,易于维护 编辑器集成 配置可序列化,在 Timeline 中直观编辑 易于扩展 新功能只需扩展现有类,不影响整体结构 性能与同步 与 Timeline 时间轴精确同步,避免自己写 Update 带来的不同步或性能问题
SkillHitController(更新后)
目前似乎是敌人的攻击调用这个方法——检查玩家状态进行代码操作
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class SkillHitController : MonoBehaviour
{EffectService effectService;EffectConfig effectConfig;PlayerState state;SkillEffect skillEffect;int hit_unit_max;//击中最多次数(New)//设定某一特定角色,能命中的次数(New)Dictionary<int, int> hit_objs = new Dictionary<int, int>();public void Init(EffectService effectService, EffectConfig effectConfig, PlayerState state, SkillEffect skillEffect){this.effectService = effectService;this.effectConfig = effectConfig;this.state = state;this.skillEffect = skillEffect;//击中次数设定——如果特效配置表中写入了数值则用设定的数值,没有则设定击中次数为1hit_unit_max = effectConfig.unit_hit_count == 0 ? 1 : effectConfig.unit_hit_count;hit_objs.Clear();}public void OnTriggerEnter(Collider other){var fsm = other.GetComponent<FSM>();if (fsm != null){检查 hit_objs 字典中是否已经记录过这个目标,如果没有,初始化命中次数为 0if (hit_objs.ContainsKey(fsm.instance_id) == false){hit_objs[fsm.instance_id] = 0;}if (fsm.IsBlockState()){//判断下前后方向if (fsm._transform.ForwardOrBack(effectService.player._transform.position) > 0){var d = Vector3.Distance(fsm._transform.position, effectService.player._transform.position);if (d <= state.skill.block_distance){fsm.OnBlockSucces(effectService.player);effectService.player.BeBlock(fsm);//1.生成格挡时特效var blockEffect = ResourcesManager.Instance.Create_Skill(CombatConfig.Instance.Config().block_effect);if (blockEffect != null){/*blockEffect.transform.position = fsm.GetBlockEffectPoint();*/blockEffect.transform.forward = fsm.transform.forward;}//镜头模糊控制 /*GameEvent.DORadialBlur?.Invoke(CombatConfig.Instance.Config().block_radialBlur);*///顿帧GameEvent.DOHitlag?.Invoke(CombatConfig.Instance.Config().block_hitlag.frame,CombatConfig.Instance.Config().block_hitlag.lerp);//放格挡成功的音效AudioController.Instance.Play(CombatConfig.Instance.Config().block_audio, blockEffect.transform.position);skillEffect.DODestroy();return;}}}if (hit_objs[fsm.instance_id] < hit_unit_max){hit_objs[fsm.instance_id] += 1;if (other.gameObject.layer != effectService.player._gameObject.layer){if (effectConfig.hit_effect_count == 0 || skillEffect.hit_effect_count < effectConfig.hit_effect_count){//生成命中的特效var hit_effect = ResourcesManager.Instance.Create_Skill(CombatConfig.Instance.GetHitEffectPath(effectConfig.hit_effect));hit_effect.transform.position = fsm.hit_target.transform.position;hit_effect.transform.forward = this.transform.position - fsm.hit_target.transform.position;skillEffect.hit_effect_count += 1;}//敌方扣血var damage = AttHelper.Instance.Damage(effectService.player, state, fsm);fsm.UpdateHP_OnHit(damage);var fb = fsm._transform.ForwardOrBack(this.transform.position) > 0 ? 0 : 1;if (fsm.att_crn.hp > 0){if (state.skill.add_fly != null){//击飞的流程fsm.OnBash(fb, effectService.player, state.skill.add_fly, this.transform.position);}else{fsm.OnHit(fb, effectService.player);}}else{fsm.OnDeath(fb);}//命中时的顿帧this.effectService.player.Attack_Hitlag(state);//6.命中的音效AudioController.Instance.Play(effectConfig.hit_audio, this.transform.position);if (hit_objs.Count >= effectConfig.destroy_hit_count){skillEffect.DODestroy();}}}}}}
格挡相关逻辑
这里需要了解这段代码的前提——攻击检测的碰撞体与特效生成机制结合在一起了,所以对敌人的攻击需要进行额外的格挡机制
这段代码的格挡机制是玩家格挡敌人的攻击
代码逻辑如下:
1、先给敌人的攻击添加该脚本
2、如果敌人的攻击碰撞器与玩家角色碰撞器重合则判断玩家是否处于格挡状态
if (fsm.IsBlockState())
3、判断玩家与发动该攻击的角色前后位置关系与身位距离——如果达到攻击范围且格挡成功,敌人则进入被格挡状态,执行被格挡状态接口的操作
函数fsm.GetBlockEffectPoint();——获取格挡特效挂点位置
就是对于这种新情况的特定脚本,出现特定方法和特定参数(因为原有格挡机制的位置参数hitInfo.point;在这里不便使用)
public void OnTriggerEnter(Collider other){var fsm= other.GetComponent<FSM>();if (fsm!=null){…………if (fsm.IsBlockState())//判断玩家是否处于格挡状态{//判断下前后方向if (fsm._transform.ForwardOrBack(effectService.player._transform.position)>0){var d = Vector3.Distance(fsm._transform.position, effectService.player._transform.position);if (d<= state.skill.block_distance){fsm.OnBlockSucces(effectService.player);effectService.player.BeBlock(fsm);//1.生成格挡时特效var blockEffect = ResourcesManager.Instance.Create_Skill(CombatConfig.Instance.Config().block_effect);if (blockEffect != null){blockEffect.transform.position = fsm.GetBlockEffectPoint();blockEffect.transform.forward = fsm.transform.forward;}//顿帧 GameEvent.DOHitlag?.Invoke(CombatConfig.Instance.Config().block_hitlag.frame,CombatConfig.Instance.Config().block_hitlag.lerp);//放格挡成功的音效 AudioController.Instance.Play(CombatConfig.Instance.Config().block_audio, blockEffect.transform.position);skillEffect.DODestroy();return;}}}
击中次数相关逻辑
hit_objs[fsm.instance_id]这个字典是用来存储当前目标的击中次数:比如玩家1001
其特有的instance_id作为Key,击中次数为Value(hit_objs[fsm.instance_id])
int hit_unit_max;public void OnTriggerEnter(Collider other){var fsm = other.GetComponent<FSM>();if (fsm != null){检查 hit_objs 字典中是否已经记录过这个目标,如果没有,初始化命中次数为 0if (hit_objs.ContainsKey(fsm.instance_id) == false){hit_objs[fsm.instance_id] = 0;}………………//格挡相关//判断当前目标是否还能被命中。if (hit_objs[fsm.instance_id] < hit_unit_max){hit_objs[fsm.instance_id] += 1;//记录当前目标击中次数(多击中一次,新增一次)if (other.gameObject.layer != effectService.player._gameObject.layer)//防止同阵营互相攻击{ //控制特效数量(配置表中特效数量无限制或者已播放特效的次数满足(针对数量有限的情况))if (effectConfig.hit_effect_count == 0 || skillEffect.hit_effect_count < effectConfig.hit_effect_count){//生成命中的特效var hit_effect = ResourcesManager.Instance.Create_Skill(CombatConfig.Instance.GetHitEffectPath(effectConfig.hit_effect));hit_effect.transform.position = fsm.hit_target.transform.position;hit_effect.transform.forward = this.transform.position - fsm.hit_target.transform.position;skillEffect.hit_effect_count += 1;}
if (effectConfig.hit_effect_count == 0 || skillEffect.hit_effect_count < effectConfig.hit_effect_count)
前者意味着命中特效为数量无限制的情况下
[Header("命中时特效数量:0无限制 1只创建一次")] public int hit_effect_count;
后者是数量有限制,且未到达播放次数
skillEffect.hit_effect_count < effectConfig.hit_effect_count)
锁定玩家相关代码
我们先确立atk_fsm是什么:
atk_fsm
(以及相关的atk_target
)既可以指玩家,也可以指敌人,具体取决于当前 FSM 实例是谁
- 当 敌人 被玩家攻击时,敌人的
atk_fsm
会指向玩家(攻击者)。- 当 玩家 被敌人攻击时,玩家的
atk_fsm
会指向那个敌人(攻击者)。
这段代码功能场景如下:
多个敌人角色技能攻击玩家,出现atk_fsm._transform 报空的情况——可能有多个因素,比如共享一个atk_fsm引用,玩家fsm还未初始化
它在ForceLockPlayer()中就避免了atk_fsm == null为空的情况,并且一定会返回玩家的fsm
public void ForceLockPlayer(){if (atk_fsm == null){atk_fsm = UnitManager.Instance.player;}}
public FSM GetAtkTarget(){ForceLockPlayer();return atk_fsm;}
召唤机制相关代码
SummonService
- OnBegin:重置所有召唤执行标记,准备开始召唤流程。
- OnUpdate:根据动画归一化时间,按配置在指定时机召唤 NPC,并设置其位置、朝向、目标和状态。
- OnEnd:状态正常结束时的回调(当前无额外逻辑)。
- OnDisable:状态被中断或禁用时的回调(当前无额外逻辑)。
- OnAnimationEnd:动画播放结束时的回调(当前无额外逻辑)。
- ReStart:状态重新开始时的回调(当前无额外逻辑)。
- ReLoop:状态循环时的回调(当前无额外逻辑)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class SummonService : FSMServiceBase
{public override void OnAnimationEnd(PlayerState state){base.OnAnimationEnd(state);}public override void OnBegin(PlayerState state){base.OnBegin(state);ReSetAllExcuted();}public override void OnDisable(PlayerState state){base.OnDisable(state);}public override void OnEnd(PlayerState state){base.OnEnd(state);}public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);var config = state.stateEntity.summonConfigs;if (config != null && config.Count > 0){for (int i = 0; i < config.Count; i++){if (GetExcuted(i) == true){continue;}var item = config[i];if (normalizedTime >= item.trigger){for (int j = 0; j < item.count; j++){if (UnitManager.Instance.CanCreate(item.npc_id, item.max_count) == false){SetExcuted(i);break;}var fsm = UnitManager.Instance.CreateNPC(item.npc_id);if (fsm != null){//设置位置 角度fsm._transform.position = this.player._transform.GetOffsetPoint(UnityEngine.Random.Range(item.distance_min,item.distance_max), UnityEngine.Random.Range(item.angle_min, item.angle_max));fsm._transform.forward = this.player._transform.forward;fsm.SetAtkTarget(this.player.GetAtkTarget());if (item.state != 0){fsm.ToNext(item.state);}}}SetExcuted(i);}}}}public override void ReLoop(PlayerState state){base.ReLoop(state);}public override void ReStart(PlayerState state){base.ReStart(state);}
}
OnUpdate
public override void OnUpdate(float normalizedTime, PlayerState state)
{ base.OnUpdate(normalizedTime, state);// 获取当前状态绑定的召唤配置列表var config = state.stateEntity.summonConfigs;// 如果配置存在且不为空,则尝试执行召唤逻辑if (config != null && config.Count > 0){// 遍历每一个召唤项(每个项代表一个召唤时机和规则)for (int i = 0; i < config.Count; i++){// 如果该项已经执行过,则跳过,避免重复召唤if (GetExcuted(i) == true){continue;}var item = config[i];// 检查当前动画播放进度是否达到该召唤项的触发时间点if (normalizedTime >= item.trigger){// 根据配置,尝试召唤指定数量(item.count)的 NPCfor (int j = 0; j < item.count; j++){// 检查是否还能创建该类型的 NPC(受全局最大数量限制)if (UnitManager.Instance.CanCreate(item.npc_id, item.max_count) == false){// 若已达上限,标记该项为已执行并跳出循环,不再继续创建SetExcuted(i);break;}// 创建一个 NPC 实例var fsm = UnitManager.Instance.CreateNPC(item.npc_id);if (fsm != null){// 设置 NPC 出生位置:在玩家前方随机距离和角度的偏移点fsm._transform.position = this.player._transform.GetOffsetPoint(UnityEngine.Random.Range(item.distance_min, item.distance_max), // 随机距离UnityEngine.Random.Range(item.angle_min, item.angle_max) // 随机角度(相对于玩家朝向));// 使 NPC 朝向与玩家一致fsm._transform.forward = this.player._transform.forward;// 设置 NPC 的攻击目标为玩家当前的攻击目标fsm.SetAtkTarget(this.player.GetAtkTarget());// 如果配置指定了初始状态(非0),则让 NPC 立即切换到该状态if (item.state != 0){fsm.ToNext(item.state);}}}// 无论是否成功创建所有 NPC,只要触发时间到达,就标记该项为已执行SetExcuted(i);}}}
}
UnitManager
public class UnitManager
{static UnitManager instance = new UnitManager();public static UnitManager Instance => instance;public FSM player;public GameObject CreatePlayer(){…………//Main初运行时创建角色,不参与召唤机制}//npc参数管理召唤物的FSM组件(即FSM的相关内容)Dictionary<int, List<FSM>> npc = new Dictionary<int, List<FSM>>();public bool CanCreate(int id, int max){if (npc.TryGetValue(id, out var v)){return v.Count < max;}return true;}public void RemoveNPC(FSM fsm){if (npc.TryGetValue(fsm.id, out var v)){v.Remove(fsm);}}public FSM CreateNPC(int id){var config = Game.Config.UnitData.Get(id);if (config != null){var go = ResourcesManager.Instance.Instantiate<GameObject>(config.prefab_path);var fsm = go.GetComponent<FSM>();fsm.AI = true;if (npc.ContainsKey(id) == false){npc[id] = new List<FSM>();}npc[id].Add(fsm);return fsm;}return null;}}
CreateNPC
1、获取对应单位配置面板数据,并且根据路径创建实例
2、每创建一个单位就获取该单位的FSM组件,并存入全局字典npc中
3、记录了fsm后,将该组件返回到调用方
public FSM CreateNPC(int id)
{// 1. 从全局配置表中查找该ID对应的配置面板数据var config = Game.Config.UnitData.Get(id);// 2. 如果配置不存在,说明该ID无效,直接返回nullif (config != null){// 3. 从资源管理器加载并实例化对应的Prefab(GameObject)var go = ResourcesManager.Instance.Instantiate<GameObject>(config.prefab_path);// 4. 获取该GameObject上的FSM组件(有限状态机,控制AI行为)var fsm = go.GetComponent<FSM>();// 5. 标记该单位为AI控制(区别于玩家角色)fsm.AI = true;// 6. 如果该ID类型的NPC列表尚未创建,则初始化一个空列表if (npc.ContainsKey(id) == false){npc[id] = new List<FSM>();}// 7. 将新创建的FSM实例加入对应ID的列表中,用于后续数量统计和管理npc[id].Add(fsm);// 8. 返回FSM组件,供调用方(如SummonService)设置位置、目标、状态等return fsm;}// 配置不存在,创建失败return null;
}
Q1:为什么用
List<FSM>
而不是List<GameObject>
或直接计数?A1:
1. FSM 是行为控制的核心
- 在这个架构中,每个单位(无论是玩家还是NPC)的行为逻辑都由
FSM
(有限状态机)组件驱动。- 后续需要对单位进行操作时(如:
- 设置攻击目标(
fsm.SetAtkTarget(...)
),- 切换状态(
fsm.ToNext(...)
),- 销毁时通知管理器(
RemoveNPC(fsm)
)), 直接持有FSM
引用比持有GameObject
更高效、更语义清晰。2. 便于精确移除和生命周期管理
- 当 NPC 死亡或被销毁时,通常会在
FSM
的某个状态(如“死亡”)中调用:UnitManager.Instance.RemoveNPC(this); // this 就是 FSM 自身
- 如果列表存的是
GameObject
,就需要额外通过GetComponent<FSM>()
才能匹配,或者在销毁时难以准确找到对应条目。- 而 FSM 组件的引用是唯一的、稳定的,且与单位生命周期一致。
3. 避免重复或无效引用
GameObject
可能被销毁(Destroy(go)
),但如果你只存了GameObject
引用,之后访问会报错或为空。- 而在设计良好的系统中,
FSM
组件会在单位销毁前主动调用RemoveNPC
,确保列表中不会残留无效引用。- 即便如此,存
FSM
也比存GameObject
更贴近“逻辑实体”而非“渲染实体”。4. 数量统计的本质是“活跃行为体”的数量
max_count
限制的不是“有多少个模型”,而是“有多少个正在运行AI逻辑的单位”。FSM
正是这个“活跃行为体”的代表。一个没有FSM
的 GameObject 只是个空壳。5. 性能与一致性
- 后续如
CanCreate
、RemoveNPC
等方法都围绕FSM
设计,保持数据结构一致,减少类型转换和查找开销。
CanCreate
根据SummonService传递来的面板——最多召唤人数max。
判断是否还能继续生成角色
//npc参数管理召唤物的FSM组件(即FSM的相关内容)Dictionary<int, List<FSM>> npc = new Dictionary<int, List<FSM>>();public bool CanCreate(int id, int max){if (npc.TryGetValue(id, out var v)){return v.Count < max;}return true;}
前后摇机制——CheckConfig(FSM)更新
public bool overwrite_atk; // 是否使用 Unity 编辑器中设置的前后摇参数(true),还是用外部传入的 config(false) public float atk_before; // 攻击前摇时长(归一化时间,0~1) public float atk_after; // 攻击后摇开始时间(归一化时间,0~1)
情况一:
overwrite_atk == true
(使用 Unity 编辑器中的参数)if ((animationService.normalizedTime >= 0 && animationService.normalizedTime <= atk_before)|| animationService.normalizedTime >= atk_after) {return true; }
情况二:
overwrite_atk == false
(使用外部 config 参数)if ((animationService.normalizedTime >= 0 && animationService.normalizedTime <= config[0])|| animationService.normalizedTime >= config[1]) {return true; }