前言

基于行为树的Moba技能系统系列文章总目录:https://www.lfzxb.top/nkgmoba-totaltabs/

在开始正文之前感觉还是有必要说明为什么需要开发基于行为树的可视化节点技能编辑器(本文简称技能编辑器),并且推荐大家去看下我之前写过的一篇回答:unity怎么去实现act战斗?

试想一下,英雄的技能多种多样,很多技能释放的流程,产生的效果都不一样,可以简单纯粹到如蛮王Q技能的主动回血(泰达米尔消耗怒气,回复生命值),也可以像瑞文那样的三段Q那般复杂(锐雯向前直冲,发起突袭。这个技能可以再次施放另外的2段。 第一段和第二段:向前斩击,对接触到的所有敌人造成物理伤害。 第三段:跃向空中,随后猛击地面,造成物理伤害,并且以冲击点为中心,将周围的敌人击飞。),或者像诺克萨斯之手那让人头痛的被动(在德莱厄斯用斧刃对敌人造成伤害时,敌人会流血,在5秒里持续受到物理伤害,最多叠加5次.
只要有一名敌方英雄身上的【出血】效果叠到最大层数,或死于【诺克萨斯断头台】 ,德莱厄斯就会获得【诺克萨斯之力】,持续5秒,获得额外攻击力,并对命中的敌人施加最大层数的【出血】效果.),再比如会生成有复杂逻辑的飞行物的女警Q(凯特琳加速转动她步枪来射出一颗穿刺弹,造成物理伤害。在子弹命中第一个单位后,它会绽开为一个更宽的弹体,造成物理伤害。被90口径绳网显形的敌人总会受到全额伤害。。),又或者是如塞拉斯,佛耶戈直接窃取其他英雄技能的技能机制。。。假使我们全部使用Excel配表,用枚举,表跳转来做,这些技能流程,效果,那么技能系统的表将会演变成何等复杂,庞大的怪物,难以维护,拓展。

但是我们如果使用技能编辑器来开发,这些问题全都可以迎刃而解,对于技能流程,完全可以依靠行为树的运行机制来直观的开发和控制整个技能流程,具体可以参照 守望先锋武器和技能和系统Statescript实例 ,思路和效果完全一致,对于技能产生的效果,实际上很多复杂效果都是由一个个的原子效果组合起来的, 所以我们可以把非常多重复的功能都封装到节点里,然后通过节点组合来实现这个技能的最终效果,对于会生成飞行物/召唤物的技能,我们就专门给这个飞行物/召唤物制作一个行为树,因为我们可以完全把他们抽象成一个AI,他们有自己的逻辑,或者也可能随时被召唤主控制着,对于最后一类特殊的技能类型,我们偷不得懒,只能把所有英雄的技能全部为他制作一个特殊版本(因为某些技能机制脱离了原英雄后,会有非常奇怪的表现(譬如豹女的R,杰斯的R),然后技能加成也可能不是原来的加成形式),然后拆分成一个个子树,供运行时进行组装,但是其实已经非常不错了,换成Excel,怎么做呢,降维到Code这边吗?

我想请读者好好想想,思考一下,我针对这些问题提出的解决方案是否有用,高效,直观,简便,这些观点将始终贯穿这个系列文章,可以说是本技能系统的起源,指导思想了,如果认同了我的观点,就可以接着看下面的开发细节啦。

可视化插件技术选型

现在市面/开源社区里有很多可视化插件供我们选择,对于我们即将制作的技能编辑器来说,相较于自带了很多绑定功能的可视化方案(Node Canvas,Bolt,FlowCanvas,Behavior Designer等),我更推荐大家选择纯粹的可视化插件(Node_Editor_Framework,xNode),这样更方便自己做定制。当然了,在其他情况下,如果那些绑定的功能正好满足你的需求,那也是很好的,毕竟一切为产品服务。对于这方面的更多内容,大家可以去看一下我之前出的一个视频:想拥有一套自己的Unity可视化工作流?我来带你一网打尽!

那么我们选择的范围就只剩Node_Editor_Framework,xNode二选一了,给大家瞅两眼这俩长啥样

Node_Editor_Framework

image-20210216195602167

xNode

image-20210216195723235

虽然我项目用的是Node_Editor_Framework,但其实我更推荐大家使用Xnode来开发,Xnode的基建更全面一些,比如Redo/Undo,多框选等,代码也比较简洁,不像Node_Editor_Framework那样有一堆冗余功能,与Odin的相性也更好一些,最重要的,颜值也更高一些。

我用Node_Editor_Framework仅仅是因为我先接触的它(明明是我先来的,为什么。。。哦,就是选的我啊,那没事了),当时年轻不懂事,就硬着头皮把整个插件魔改了,裁剪大量代码,重写Redo/Undo,选中节点高亮,重写Group,重写右键菜单等,还好结果可以接受,目前我已经能非常熟练地用它进行开发各种功能的编辑器了。

还有一个重要问题——序列化,Unity的半吊子序列化根本满足不了我们会在技能编辑器中用到的各种复杂的容器和数据类型,所以我们需要神器——Odin,对于Odin的介绍这里就不多说了,没听说过的可以百度一下,简而言之就是他内部有一套序列化系统,并且支持通过元数据标记的方式来快速将数据可视化出来。

Node_Editor_Framework和Xnode接入Odin的方式都很简单,几行代码的事。

到这里其实就已经可以做出这样的效果了:(这个EditorWindow下文称为Canvas

技能编辑器v0.0.3

不过仅仅有这空壳子也没用,我们要给他加上灵魂

(PS:其实还有一种选择,那就是基于UnityGraphView的节点编辑器,但是当时的我在制作技能编辑器的时候还没听说这东西,有些可惜,不过对于GraphView相关内容,我也写了一篇文章:https://www.lfzxb.top/nodegraphprocesssor-and-odin/ 里面详细介绍了它的优势以及接入Odin的过程,其实我是更看好它的,后续也准备将可视化方案移植到UnityGraphView上)

行为树技术选型

行为树也有几十年的历史了,以便捷,直观便于维护著称,多用来做怪物的AI。

先提一下基于事件的行为树,相对于传统行为树,基于事件的行为树最大的优势是可以在条件不变的情况下不执行任何多余结点,而传统行为树需要不断地进行Condition节点的判断来决策整棵行为树的状态。基于事件的行为树的性能优势同传统行为树相比高下立判。

所以就直接选用了Github上的NPBehave,不过可惜的是原作者并没有提供可视化编辑工具,是纯代码的形式创建一颗行为树,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
behaviorTree = new Root(
new Service(0.5f, () => { behaviorTree.Blackboard["foo"] = !behaviorTree.Blackboard.Get<bool>("foo"); },
new Selector(

new BlackboardCondition("foo", Operator.IS_EQUAL, true, Stops.IMMEDIATE_RESTART,
new Sequence(
new Action(() => Debug.Log("foo")),
new WaitUntilStopped()
)
),

new Sequence(
new Action(() => Debug.Log("bar")),
new WaitUntilStopped()
)
)
)
);
behaviorTree.Start();

那肯定是不行的,所以我们要通读一遍源码,然后考虑怎么把它和我们的可视化方案结合起来。

可以看一下我总结的NPBehave行为树架构

行为树的可视化与数据导出

我们可以从刚刚那张图看到整个Canvas包含两部分,一个是技能逻辑部分(也就是行为树部分),一个是Buff数据部分,Buff数据部分比较简单,我们先来看行为树部分的处理

具体的流程架构,我们只需要分离好Editor和Runtime Data即可,Editor依赖于Runtime Data,但Runtime Data不能依赖于Editor,这也是插件开发的准则,一图胜千言:

为NPBehave而生的可视化编辑器架构

首先是GUI这边,为Canvas做了按钮,用于序列化为二进制文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[Button("自动配置所有结点数据", 25), GUIColor(0.4f, 0.8f, 1)]
public void AddAllNodeData()
{
base.AutoSetCanvasDatas(MNpDataSupportor.NpDataSupportorBase);
this.AutoSetSkillData_NodeData();
}

[Button("保存行为树信息为二进制文件", 25), GUIColor(0.4f, 0.8f, 1)]
public void Save()
{
if (string.IsNullOrEmpty(SavePath) || string.IsNullOrEmpty(Name))
{
Log.Error("保存路径或文件名不能为空,请检查配置");
return;
}
using (FileStream file = File.Create($"{SavePath}/{this.Name}.bytes"))
{
//这就是使用Bson序列化数据为二进制文件的过程
BsonSerializer.Serialize(new BsonBinaryWriter(file), MNpDataSupportor);
}
Debug.Log($"保存 {SavePath}/{this.Name}.bytes 成功");
}

[Button("测试反序列化", 25), GUIColor(0.4f, 0.8f, 1)]
public void TestDe()
{
//客户端使用AB包加载二进制数据,便于热更新,服务端则会像这样直接读取路径文件
byte[] mfile = File.ReadAllBytes($"{SavePath}/{this.Name}.bytes");
if (mfile.Length == 0) Debug.Log("没有读取到文件");
try
{
//这就是使用Bson反序列化二进制文件为对象的过程
MNpDataSupportor1 = BsonSerializer.Deserialize<NP_DataSupportor>(mfile);
}
catch (Exception e)
{
Debug.Log(e);
throw;
}
}

导出的技能行为树二进制文件(客户端和服务端)

image-20210216220019539

image-20210216215851272

其中比较关键的代码是行为树基础节点数据的导出(AutoSetCanvasDatas)和重建(CreateNpRuntimeTree )代码,也没什么算法,将行为树节点排好序序列化反序列化就好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
/// <summary>
/// 自动配置当前图所有数据(结点,黑板)
/// </summary>
/// <param name="npDataSupportorBase">自定义的继承于NP_DataSupportorBase的数据体</param>
public virtual void AutoSetCanvasDatas(NP_DataSupportorBase npDataSupportorBase)
{
this.AutoSetNP_NodeData(npDataSupportorBase);
this.AutoSetNP_BBDatas(npDataSupportorBase);
}

/// <summary>
/// 自动配置所有行为树结点
/// </summary>
/// <param name="npDataSupportorBase">自定义的继承于NP_DataSupportorBase的数据体</param>
private void AutoSetNP_NodeData(NP_DataSupportorBase npDataSupportorBase)
{
npDataSupportorBase.NP_DataSupportorDic.Clear();
//当前Canvas所有NP_Node
List<NP_NodeBase> allNodes = new List<NP_NodeBase>();
foreach (var node in this.nodes)
{
if (node is NP_NodeBase mNode)
{
allNodes.Add(mNode);
}
}
//排序
allNodes.Sort((x, y) => -x.position.y.CompareTo(y.position.y));
//配置每个节点Id
foreach (var node in allNodes)
{
node.NP_GetNodeData().id = IdGenerater.GenerateId();
}
//设置根结点Id
npDataSupportorBase.RootId = allNodes[allNodes.Count - 1].NP_GetNodeData().id;
foreach (var node in allNodes)
{
//获取结点对应的NPData
NP_NodeDataBase mNodeData = node.NP_GetNodeData();
if (mNodeData.LinkedIds == null)
{
mNodeData.LinkedIds = new List<long>();
}
mNodeData.LinkedIds.Clear();
//出结点连接的Nodes
List<NP_NodeBase> theNodesConnectedToOutNode = new List<NP_NodeBase>();
List<ValueConnectionKnob> valueConnectionKnobs = node.GetNextNodes()?.connections;
if (valueConnectionKnobs != null)
{
foreach (var valueConnectionKnob in valueConnectionKnobs)
{
theNodesConnectedToOutNode.Add((NP_NodeBase) valueConnectionKnob.body);
}
//对所连接的节点们进行排序
theNodesConnectedToOutNode.Sort((x, y) => x.position.x.CompareTo(y.position.x));
//配置连接的Id,运行时实时构建行为树
foreach (var npNodeBase in theNodesConnectedToOutNode)
{
mNodeData.LinkedIds.Add(npNodeBase.NP_GetNodeData().id);
}
}
//将此结点数据写入字典
npDataSupportorBase.NP_DataSupportorDic.Add(mNodeData.id, mNodeData);
}
}

/// <summary>
/// 自动配置黑板数据
/// </summary>
/// <param name="npDataSupportorBase">自定义的继承于NP_DataSupportorBase的数据体</param>
private void AutoSetNP_BBDatas(NP_DataSupportorBase npDataSupportorBase)
{
npDataSupportorBase.NP_BBValueManager.Clear();
//设置黑板数据
foreach (var bbvalues in this.GetCurrentCanvasDatas().BBValues)
{
npDataSupportorBase.NP_BBValueManager.Add(bbvalues.Key, bbvalues.Value);
}
}

/// <summary>
/// 创建一个行为树实例,默认存入Unit的NP_RuntimeTreeManager中
/// </summary>
/// <param name="unit">行为树所归属unit</param>
/// <param name="nPDataId">行为树数据id</param>
/// <returns></returns>
public static NP_RuntimeTree CreateNpRuntimeTree(Unit unit, long nPDataId)
{
//只需要深拷贝黑板数据即可,因为我们反序列化出来的行为树节点数据是无状态的,完全可以复用
NP_DataSupportor npDataSupportor =
Game.Scene.GetComponent<NP_TreeDataRepository>().GetNP_TreeData_DeepCopyBBValuesOnly(nPDataId);
。。。
//配置节点数据
foreach (var nodeDateBase in npDataSupportor.NpDataSupportorBase.NP_DataSupportorDic)
{
switch (nodeDateBase.Value.NodeType)
{
case NodeType.Task:
nodeDateBase.Value.CreateTask(unit.Id, tempTree);
break;
case NodeType.Decorator:
nodeDateBase.Value.CreateDecoratorNode(unit.Id, tempTree,
npDataSupportor.NpDataSupportorBase.NP_DataSupportorDic[nodeDateBase.Value.LinkedIds[0]].NP_GetNode());
break;
case NodeType.Composite:
List<Node> temp = new List<Node>();
foreach (var linkedId in nodeDateBase.Value.LinkedIds)
{
temp.Add(npDataSupportor.NpDataSupportorBase.NP_DataSupportorDic[linkedId].NP_GetNode());
}
nodeDateBase.Value.CreateComposite(temp.ToArray());
break;
}
}
。。。
//配置黑板数据
Dictionary<string, ANP_BBValue> bbvaluesManager = tempTree.GetBlackboard().GetDatas();
foreach (var bbValues in npDataSupportor.NpDataSupportorBase.NP_BBValueManager)
{
bbvaluesManager.Add(bbValues.Key, bbValues.Value);
}
return tempTree;
}

当然,上述流程一些地方还可以再做优化,比如CreateNpRuntimeTree中switch创建的节点,就可以进行池化,进一步降低消耗,再比如深拷贝出来的黑板数据也可以做一下池化,用完之后直接根据原始数据重置一下,可以再复用,等等

技能配置浏览器

为了满足技能的数据预览和导出需求,我还制作了一个技能配置浏览器,可以直观的查看,搜索,操作项目中所有的技能配置,功能也如下图所示,一目了然:

image-20210529164438150

技能和Buff数据设计

这部分的数据处理和行为树差不多,所以对于数据的序列化和反序列化部分不会讲的太详细(事实上此类工具的开发在数据划分形式上都差不多,分好Editor和Runtime就行了),这里只是简单说一下Buff数据结构和配置流程,对于更加详细的Buff系统思考和设计,我会单独出一篇文章来说明

技能系统设计

其中比较有意思的地方是我利用连线来表述Buff之间的关系,比如一个BindStateBuff节点 A后面连了一个FlashDamageBuff节点 B和另一个BindStateBuff节点 C,这个BindStateBuff节点 C后面连了另一个FlashDamageBuff节点 D,这样,在导出数据的时候,点一下Canvas上的“自动配置所有结点数据”,就会自动将相关Buff的Id注册。在运行时添加A Buff的时候,就会连锁添加B,C,D这三个Buff。这样就实现了直观,有序,可控的Buff组合。

以BindStateBuff Node的处理连线数据(注册相连Buff的Id)为例,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public override void AutoAddLinkedBuffs()
{
BindStateBuffData bindStateBuffData = SkillBuffBases.BuffData as BindStateBuffData;
if (bindStateBuffData.OriBuff == null)
{
bindStateBuffData.OriBuff = new List<VTD_BuffInfo>();
}
。。。
bindStateBuffData.OriBuff.Clear();
foreach (var connection in this.connectionPorts)
{
//只有出方向的端口才是添加LinkedBuffId的地方
if (connection.direction == Direction.Out)
{
foreach (var connectTagrets in connection.connections)
{
BuffNodeBase targetNode = (connectTagrets.body as BuffNodeBase);
if (targetNode != null)
{
bindStateBuffData.OriBuff.Add(new VTD_BuffInfo()
{
BuffNodeId = targetNode.Skill_GetNodeData().NodeId
});
}
}
。。。
return;
}
}
}

技能编辑器致命痛点分析与解决方案(黑板数据仓库)

我们都知道Excel数据表的一大优点就是数据直观,方便批量修改,但是这却是技能编辑器的短肋,因为我们打开一个技能的Canvas的时候,看到的就是这个技能的数据,而且对于技能编辑器而言,更加直观的是逻辑,而不是数据,那么在需要对大量技能的数据进行整理的时候(比如Id,事件名,特效名,动画名的整理和修改),弊病就暴露无遗了。

我们充分发挥了技能编辑器逻辑直观的优势,但是却没有做到扬长避短,所以我们需要给出一种两全其美的方案,结合技能编辑器和Excel两者的优点。

我给出的答案是利用行为树的黑板数据仓库,这个模块主要有三个重要的地方

  • 解决数据赋值时的GC问题(因为黑板默认数据存储类型是System.Object),我之前写过一篇文章:实现行为树黑板模块0GC赋值功能
  • 解决在编辑器中填写字符串/Id容易出错的问题
  • 支持自动/手动从Excel表读取的数据来更新黑板数据仓库中的数据,
  • 提供引用机制,黑板数据仓库更新后,在技能Canvas中已填充的数据会自动更新

解决在编辑器中填写字符串/Id容易出错的问题

对于第二点,我们先来分析一下问题的来源,在给节点填充数据的时候,往往会涉及一些字符串和Id的填写,这些个东西就算填错了,导出数据的时候也不会报错,也就不好找到错的地方,所以需要尽量降低出错的可能性,解决方案也很简单,将此Canvas所有的数据汇总到一个黑板数据仓库里,封装数据类型,做好键值对映射,最后利用Odin来实现下拉框的形式的数据填充,最大限度的减少出错成本。

这里以两个最具代表性的例子来解释说明(实际上专门为黑板相关节点配置的数据也是类似Id的形式来处理的):事件(string)和Id(long)

image-20210217153713347

先来看事件的封装,因为事件名往往都是顾名思义的字符串,所以这里直接一个string就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
namespace ETModel
{
[HideReferenceObjectPicker]
public struct VTD_EventId
{
[ValueDropdown("GetEventId")]
public string Value;

//因为我们Runtime的时候不需要这些Editor下的功能,所以用宏包起来
#if UNITY_EDITOR
private IEnumerable<string> GetEventId()
{
//读取当前的技能Canvas
UnityEngine.Object[] subAssets = AssetDatabase.LoadAllAssetsAtPath(UnityEngine.PlayerPrefs.GetString("LastCanvasPath"));
if (subAssets != null)
{
foreach (var subAsset in subAssets)
{
//取到存储黑板数据的SO文件,并且返回结果值,用于给ValueDropdown绘制下拉列表内容
if (subAsset is NPBehaveCanvasDataManager npBehaveCanvasDataManager)
{
return npBehaveCanvasDataManager.EventValues;
}
}
}

return null;
}
#endif
}
}

效果如下

image-20210217154101957

再来看看Id的封装,因为Id只是一个数字,辨识度不是很高,所以我们要做一层string映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
namespace ETModel
{
[HideReferenceObjectPicker]
public struct VTD_Id
{
[LabelText("此节点ID在数据仓库中的Key")]
[ValueDropdown("GetIdKey")]
[OnValueChanged("ApplayId")]
[BsonIgnore]
public string IdKey;

[LabelText("Id")]
[InfoBox("无法对其直接赋值,需要在CanvasDataManager中Ids中注册键值对,然后选择NodeIdKey的值")]
[ReadOnly]
public long Value;

#if UNITY_EDITOR
private IEnumerable<string> GetIdKey()
{
string path = UnityEngine.PlayerPrefs.GetString("LastCanvasPath");

UnityEngine.Object[] subAssets = AssetDatabase.LoadAllAssetsAtPath(path);
if (subAssets != null)
{
foreach (var subAsset in subAssets)
{
if (subAsset is NPBehaveCanvasDataManager npBehaveCanvasDataManager)
{
return npBehaveCanvasDataManager.Ids.Keys;
}
}
}

return null;
}

private void ApplayId()
{
if (string.IsNullOrEmpty(IdKey))
{
return;
}
string path = UnityEngine.PlayerPrefs.GetString("LastCanvasPath");

UnityEngine.Object[] subAssets = AssetDatabase.LoadAllAssetsAtPath(path);
if (subAssets != null)
{
foreach (var subAsset in subAssets)
{
if (subAsset is NPBehaveCanvasDataManager npBehaveCanvasDataManager)
{
if (npBehaveCanvasDataManager.Ids.TryGetValue(IdKey, out var targetId))
{
Value = targetId;
}
}
}
}
}
#endif
}
}

效果如下:

image-20210217154508221

image-20210217154524326

以第二张图选择结果为例

image-20210217154632827

这样一来,我们就可以直接以下拉框的形式配置数据了

其他

对于第三点和第四点,其实我还没做(懒狗怎么说,懒狗),但是其实也简单,多一层黑板数据仓库到Excel表的映射,做几个Dic维护一下数据的引用,就行了,相信聪明的你一定能理解的。

总结

经过这些处理后,我们就可以把所有的数据都反映到这个简单直观的黑板数据仓库上面来,并且有从黑板数据仓库到Excel表的映射后我们基本上只需要在Excel那边编辑修改具体的数据就行了,非常的有人情味,策划大哥友好度++。

Demo解析

说了这么多,是时候拿出真东西给带伙康康了,这里以诺克萨斯之手(简称诺手)的Q技能和被动为例(更加详细的技能介绍和视频演示可以前往 诺手的OPGG界面,点击技能图标即可观看技能演示)

诺手被动介绍:在德莱厄斯用斧刃对敌人造成伤害时,敌人会流血,在5秒里持续受到物理伤害,最多叠加5次。只要有一名敌方英雄身上的【出血】效果叠到最大层数,德莱厄斯就会获得【诺克萨斯之力】,持续5秒,获得额外攻击力,并对命中的敌人施加最大层数的【出血】效果。

诺手Q技能介绍:在短暂的延迟后,德莱厄斯环绕自身挥舞斧头,打击附近的敌人。被斧刃(技能指示器外环)命中的敌人会受到物理伤害。被斧柄(技能指示器内环)命中的敌人只会受到前者35%的伤害(不会施加【出血】效果)。德莱厄斯每用斧刃命中一名敌方英雄,就会治疗自身12%的已损失生命值(最大值:36%)。

先放个成品视频:

被动

先来分析被动技能,可以看出由诺手Q技能外圈击中的敌人会被上一层流血Buff,一旦有一个敌人身上有5层流血Buff,诺手就会获得攻击力,并且下一次Q技能外圈击中的敌人会附加满层的流血效果,很明显需要进行事件的分发和订阅(这里的事件分发和订阅有两个含义,一个是行为树黑板的事件,一个是Buff系统中的Buff事件,行为树的事件只存在于行为树区域(在这里就是“技能逻辑块”),Buff系统的事件只存在于Buff区域(在这里就是Buff数据块)),然后深入分析一下,其实我们需要创建的Buff比表面上的多,因为诺手本身就要添加一个监听Buff,用于监听有没有英雄身上的流血Buff达到5层,还要考虑到客户端那边的流血特效的同步,所以还得有一个同步Buff,往客户端同步Buff信息逻辑块

服务端

先来看服务端这边的行为树

image-20210217193342146

行为树逻辑块比较简单,就是首次获得被动的时候进行监听Buff(用于监听有没有英雄身上的流血Buff达到5层)的添加,然后就是特定事件(普攻,Q技能外圈)触发的时候进行流血Buff的添加,当然了,Buff添加的目标也是由普攻,Q技能外圈所提供的对象

image-20210217195119183

注意一下最左边的节点,可以保证我们的行为树在没接收到事件的情况下会一直“卡”在这个节点上,从而最小化性能消耗

比较复杂的是Buff数据图这边

image-20210217194412004

最上面的那个Buff节点,就是由我们行为树区域的“宏行为节点:为目标添加流血Buff”添加的,它在添加的时候会抛出一个事件

image-20210217195321425

而这个事件会被两个监听Buff监听

image-20210217195520478

image-20210217195603683

就这样通过事件的传递,完成了整个技能机制的开发,经过这个技能的分析,大家应该理解了行为树和Buff图的运行机制,限于篇幅问题,下面不会再说的这么详细

客户端

然后就是客户端这边,客户端这边的被动技能Canvas就要简单许多了,因为没有那么复杂的逻辑,老规矩,先看行为树区域

image-20210217200435563

主要就是根据从服务器那边获取的数据来添加流血Buff和血怒Buff,并且通过行为树控制更加细层次的逻辑,比如Buff没到5层就不会播放血怒特效,注意,这里的Buff都是用来播放特效的Buff,请不要惊讶,在我的设计思路里,就是万物皆Buff的,譬如服务端的那个“同步Buff信息”Buff结点,这样做的好处是可以高度统一技能系统的功能开发,把尽可能多的逻辑以可视化的形式表现出来

然后是Buff数据块这边,因为Buff之间没什么联系,所以就是一个个单独的Buff节点,作用也是顾名思义

image-20210217201739839

Q技能

Q技能相对于被动的技能流程就会复杂很多了,毕竟他是一个主动技能,而且还会生成一个碰撞体,这个碰撞体还会有一个行为树

这部分开始之前要先说明一个特殊的东西——技能的描述结点,他描述了技能的所有信息,包括伤害,消耗,释放方式,目标,加成等,放Excel表吧,感觉与技能编辑器割裂有点严重,毕竟技能编辑器的一些节点需要频繁使用技能描述节点的信息,比如消耗,伤害等,但是把它放技能编辑器里又有点格格不入的感觉,可能我哪天突然就想通了,就知道怎么处理了,无伤大雅

服务端

老规矩,先来看服务端,首先是它的行为树区域

image-20210217202140083

一个屏幕都快放不下了,大家将就着看吧,需要注意的是以右下角那个红框,它生成了Q技能的碰撞体,我们会专门给这个碰撞体做一个行为树,当然了这里的这个碰撞体逻辑比较简单,判断一下碰撞到的敌人距离圆心的距离就行了,有点大材小用的感觉,但是这里是为了演示嘛,最后留意一下最左边的那个子树分支,是用来和这个生成的Q技能碰撞体行为树互动的

然后是它的Buff数据区域,是零散的Buff节点,因为各个Buff彼此之间没有联系

image-20210217202728880

然后我们就可来看这个碰撞体的行为树了

image-20210217202837877

因为它本身就是个AI,所以就没做技能逻辑区和Buff数据区的划分了,逻辑也是一目了然,需要注意的是从左往右第三棵子树的宏行为节点,他会往我们被动行为树和Q技能行为树的黑板发送事件,更新数据,从而激活相应的子树分支,这样就完成了Q技能,Q技能产生的碰撞体与被动三者之间的互动,那么第三棵子树激活的事件,就是由我们碰撞系统发出的,会有类似这样的代码

1
2
3
4
//通过设置黑板数据来激活子树
skillCanvas.GetBlackboard().Set("Darius_QOutIsHitUnit", true);
//将碰撞到的对象Id传入黑板
skillCanvas.GetBlackboard().Get<List<long>>("Darius_QOutHitUnitIds")?.Add(collisionBelongToUnit.Id);

客户端

Q技能的客户端部分,也比较简单,就是根据服务器发来的数据进行动画,特效的播放

image-20210217203212410

未来展望与开发计划

本文虽然实现了一个基于节点的技能编辑器,但仍然有许多地方需要改善:

  • 技能配置导出二进制文件大小需要优化,由于我们需要运行时重建行为树,所以许多地方只需要一个标识即可,而不是把整个类序列化,会有很多冗余数据
  • 黑板数据模块提到的一些待办事项
  • 编辑器UI界面优化,但不准备花大力气美化,因为即将更换新的可视化方案,目前已经做了一些UI优化,可去项目网址查看效果

除此之外,个人心中也还有许多想法待实装,具体来说:

  • 接入一个Timeline编辑器(暂定Slate),使用其制作精确帧控制的部分,例如上面例子中的Q技能延迟0.5s才生成碰撞体,应当是放在某一特定帧才触发,这样一是方便策划所见即所得,二是为后续的状态帧同步的方案打下数据基础,而行为树也不会被弃用,因为其逻辑控制能力要比Timeline强大的多,所以其继续担任逻辑控制的角色。这样行为树+Timeline结合的方式应当是比较无懈可击的一个方案了,因为其兼顾了“帧”的概念,适用范围就不仅仅是Moba这一类型了,它可以用来开发ACT这种要求极高的帧控制和精确度的游戏类型,甚至是守望先锋这种半MOBA半FPS游戏也是可以适用的。
  • 使用GraphView作为可视化方案替代Node_Editor_Framework,毕竟官方背书 + 未来趋势。

后记

本文讲述了技能编辑器从可视化技术选型到最终落地的全部开发过程,为了便于读者理解,部分代码是经过裁剪过的,甚至是一些重要部分都没有放代码,这是出于篇幅考虑权衡出来的决定,想查看原代码的可以直接去我的开源项目查看。其实也不用特地去看代码,代码谁都会写,最主要的是思路,有了思路,代码就是体力活罢了。

至此我们已经有了一个简洁,易用的技能编辑器,之后就可以进行其他模块的开发了。