目录
前言
前置章节:
[unity] 使用graphview实现一个可视化节点的事件行为树系统(序章/github下载)_sugarzo的博客-csdn博客_unity graphview
[unity] graphview 可视化节点的事件行为树(一) runtime node_sugarzo的博客-csdn博客
[unity] graphview 可视化节点的事件行为树(二) ui toolkit介绍,制作事件行为树的ui_sugarzo的博客-csdn博客
在前面两个章节中,我们实现了两个部分的内容:runtime部分的节点逻辑、ui toolkit绘制editor ui。这一章节将会用到graphview框架,完成我们制作的事件行为树剩下的所有内容。
graphview是unity内置的节点绘制系统,实际上unity里内置的visual scripting(可视化脚本编辑),urp中的shadergraph,都是graphview制作。
graphview也属于ui toolkit的一部分。在unity 2021版本中,graphview是experimental部分。需要引入命名空间:unityeditor.experimental.graphview
using unityeditor.experimental.graphview;
using unityengine;
using unityengine.uielements;
public class flowchartview : graphview
{
public new class uxmlfactory : uxmlfactory<flowchartview, graphview.uxmltraits> { }
}
回顾一下我们前两节的内容,给这一部分留下了什么内容。我们用ui builder写了一个editor窗口,里面有两个ui元素,是我们继承visual element新建的脚本元素。
flowcharview是左边的inspector窗口,该窗口的目的是显示runtime的节点数据(这里的inspector用的是unity imgui默认的inspector绘制)
在runtime部分,每个节点都是一个monobehaviour的脚本。忽视掉其他的runtime逻辑,基类给editor使用的数据只有:这个节点的位置(vector2),以及它连接的下一个节点。
public abstract class nodestate : monobehaviour
{
#if unity_editor
[hideininspector]
public vector2 nodepos;
#endif
//流向下一节点的流
public monostate nextflow;
}
了解了这些数据,我们就可以开始正式进入graphview了。
graphview的节点(node)和端口(port)
创建脚本,继承graphview.node,这就是在graphview中最基础的节点元素了。
在这里,我们先创建一个节点view的基类。
在下面的脚本里,一个节点view记录了三个数据:
1.具体关联到的runtime节点(这里指的是前文提到的nodestate)
2.当被点击时触发的委托事件(这个委托要被转发到inspector面板中)
3.给节点view创建端口函数(port)
public class basenodeview : unityeditor.experimental.graphview.node
{
/// <summary>
/// 点击该节点时被调用的事件,比如转发该节点信息到inspector中显示
/// </summary>
public action<basenodeview> onnodeselected;
public textfield textfield;
public string guid;
public basenodeview() : base()
{
textfield = new textfield();
guid = guid.newguid().tostring();
}
// 为节点n创建input port或者output port
// direction: 是一个简单的枚举,分为input和output两种
public port getportfornode(basenodeview n, direction portdir, port.capacity capacity = port.capacity.single)
{
// orientation也是个简单的枚举,分为horizontal和vertical两种,port的数据类型是bool
return n.instantiateport(orientation.horizontal, portdir, capacity, typeof(bool));
}
//告诉inspector去绘制该节点
public override void onselected()
{
base.onselected();
debug.log($"{this.name}节点被点击");
onnodeselected?.invoke(this);
}
public abstract nodestate state { get; set; }
}
因为最后我们需要实现 触发器/行为/序列/判断 四个节点的view,我们这里创建好节点基类的泛型版本。
public class basenodeview<state> : basenodeview where state : nodestate
{
/// <summary>
/// 关联的state
/// </summary>
private state _state;
public override nodestate state
{
get
{
return _state;
}
set
{
if (_state != null)
_state.node = null;
_state = (state)value;
}
}
接着就可以继承泛型版本开始写四个节点的view脚本了。在本框架中,四个节点的样式和性质如下:
触发器:只有一个输出端口,输出端口只能单连接。
事件节点:一个输入端口,一个输出端口,输入端口可以多连接,输出端口只能单连接。
条件节点:一种特殊的事件节点,有两个输出端口,都只能单连接。当条件满足时流向true,不满足时流向false
序列节点:一种特殊的事件节点,有一个支持多连接的输出端口(也是目前框架里唯一一个支持输出端口多连接的节点)
public class triggernodeview : basenodeview<basetrigger>
{
public triggernodeview()
{
title = state != null ? state.name : "triggernode";
//trigger只有一个输出端口
port output = getportfornode(this, direction.output, port.capacity.single);
output.portname = "output";
outputcontainer.add(output);
}
}
public class actionnodeview : basenodeview<baseaction>
{
public actionnodeview()
{
//action有一个输出端口一个输入端口,输入接口可以多连接
port input = getportfornode(this, direction.input, port.capacity.multi);
port output = getportfornode(this, direction.output, port.capacity.single);
input.portname = "input";
output.portname = "output";
title = state != null ? state.name : "actionnode";
inputcontainer.add(input);
outputcontainer.add(output);
}
}
public class sequencenodeview : basenodeview<basesequence>
{
public sequencenodeview()
{
//sequence有一个输出端口一个输入端口,输入接口只能单连接,输出端口可以多连接
port input = getportfornode(this, direction.input, port.capacity.single);
port output = getportfornode(this, direction.output, port.capacity.multi);
input.portname = "input";
output.portname = "output";
title = state != null ? state.name : "sequencenode";
inputcontainer.add(input);
outputcontainer.add(output);
}
}
public class branchnodeview : basenodeview<basebranch>
{
public branchnodeview()
{
//sequence有两个输出端口一个输入端口
port input = getportfornode(this, direction.input, port.capacity.multi);
port output1 = getportfornode(this, direction.output, port.capacity.single);
port output2 = getportfornode(this, direction.output, port.capacity.single);
input.portname = "input";
output1.portname = "true";
output2.portname = "false";
title = state != null ? state.name : "ifnode";
inputcontainer.add(input);
outputcontainer.add(output1);
outputcontainer.add(output2);
}
}
这样,我们就制作完成了四种类型的节点view了。
graphview的边(edge)
对于每个节点view的端口(port),都可以进行边edge的连接。对于判断每个端口间是否可以连接,除了创建这个端口选择的枚举类型port.capacity.single和port.capacity.multi外,可以重写graphview中的getcompatibleports函数(这里是一个迭代器遍历,连接判断逻辑是一个点不能和自己连接)
下列函数在flowchartview:graphview脚本中
//判断每个点是否可以相连
public override list<port> getcompatibleports(port startport, nodeadapter nodeadapter)
{
return ports.tolist().where(endport =>
endport.direction != startport.direction &&
endport.node != startport.node).tolist();
}
//连接两个点
private void addedgebyports(port _outputport, port _inputport)
{
if (_outputport.node == _inputport.node)
return;
edge tempedge = new edge()
{
output = _outputport,
input = _inputport
};
tempedge.input.connect(tempedge);
tempedge.output.connect(tempedge);
add(tempedge);
}
关联inspector窗口,显示数据
在inspectorview中给自己添加一个imguicontainer(也是visual element元素派生),添加unity自带的editor窗口。
public class inspectorview : visualelement
{
public new class uxmlfactory : uxmlfactory<inspectorview, uxmltraits> { }
editor editor;
public inspectorview()
{
}
internal void updateselection(basenodeview nodeview)
{
clear();
debug.log("显示节点的inspector面板");
unityengine.object.destroyimmediate(editor);
editor = editor.createeditor(nodeview.state);
imguicontainer container = new imguicontainer(() => {
if (nodeview != null && nodeview.state != null)
{
editor.oninspectorgui();
}
});
add(container);
}
}
将委托连接到nodeview上。其中flowchartview.onnodeselected将在创建节点的函数中绑定。
public class flowcharteditorwindow : editorwindow
{
public static void openwindow()
{
flowcharteditorwindow wnd = getwindow<flowcharteditorwindow>();
wnd.titlecontent = new guicontent("flowchart");
}
/// <summary>
/// 当前选择的游戏物品
/// </summary>
public static gameobject userseletiongo;
flowchartview flowchartview;
inspectorview inspectorview;
public void creategui()
{
// each editor window contains a root visualelement object
visualelement root = rootvisualelement;
// import uxml
var visualtree = assetdatabase.loadassetatpath<visualtreeasset>("assets/sugarzonode/editor/uibuilder/flowchart.uxml");
visualtree.clonetree(root);
// a stylesheet can be added to a visualelement.
// the style will be applied to the visualelement and all of its children.
var stylesheet = assetdatabase.loadassetatpath<stylesheet>("assets/sugarzonode/editor/uibuilder/flowchart.uss");
root.stylesheets.add(stylesheet);
//设置节点视图和inspector视图
flowchartview = root.q<flowchartview>();
inspectorview = root.q<inspectorview>();
flowchartview.onnodeselected = onnodeselectionchanged;
flowchartview.userseletiongo = userseletiongo;
flowchartview.window = this;
//构造节点
flowchartview.resetnodeview();
}
void onnodeselectionchanged(basenodeview nodeview)
{
debug.log("editor受到节点被选中信息");
inspectorview.updateselection(nodeview);
}
增加节点操作
在本框架中,所有节点都是作为一个compoment附加到游戏物品上。在上一节中,我们以及写好了一个标记当前选择的游戏物品的脚本逻辑。 创建节点需要操作对于go上的monobehaviour节点进行addcompoment。
public class flowchartview : graphview
{
public gameobject userseletiongo;
public action<basenodeview> onnodeselected;
public flowchartview()
{
userseletiongo = userseletiongo == null ? flowcharteditorwindow.userseletiongo : userseletiongo;
}
private void createnode(type type, vector2 pos = default)
{
if (userseletiongo == null)
return;
basenodeview nodeview = null;
if (type.issubclassof(typeof(basetrigger)))
nodeview = new triggernodeview();
if (type.issubclassof(typeof(baseaction)))
nodeview = new actionnodeview();
if (type.issubclassof(typeof(basesequence)))
nodeview = new sequencenodeview();
if (type.issubclassof(typeof(basebranch)))
nodeview = new branchnodeview();
if (nodeview == null)
{
debug.logerror("节点未找到对应属性的nodeview");
return;
}
//添加component,关联节点
nodeview.onnodeselected = onnodeselected;
nodeview.state = (nodestate)userseletiongo.addcomponent(type);
nodeview.setposition(new rect(pos, nodeview.getposition().size));
this.addelement(nodeview);
}
构建节点图
使用getcompoments来获取游戏物品上的所有节点,再根据它的位置和下一个流的数据,逐个创建点和边还原出来。其中resetnodeview函数可以由creategui()调用。
下列函数放在flowchartview : graphview中。
//重构布局
public void resetnodeview()
{
if (userseletiongo != null)
{
debug.log("构建节点图");
var list = userseletiongo.getcomponents<nodestate>();
foreach (var item in list)
createbasenodeview(item);
}
if (userseletiongo != null)
{
debug.log("构建节点边的关系");
createnodeedge();
}
}
//复原节点操作
private void createbasenodeview(nodestate nodeclone)
{
if (userseletiongo == null || nodeclone == null)
return;
basenodeview nodeview = null;
//判断需要复原的节点
if (nodeclone is basetrigger trigger)
nodeview = new triggernodeview();
if (nodeclone is baseaction action)
nodeview = new actionnodeview();
if (nodeclone is basesequence sequence)
nodeview = new sequencenodeview();
if (nodeclone is basebranch branch)
nodeview = new branchnodeview();
if (nodeview == null)
{
debug.logerror("节点未找到对应属性的nodeview");
return;
}
nodeview.onnodeselected = onnodeselected;
nodeview.state = nodeclone;
nodeview.setposition(new rect(nodeclone.nodepos, nodeview.getposition().size));
nodeview.refreshexpandedstate();
nodeview.refreshports();
addelement(nodeview);
}
//复原节点的边
private void createnodeedge()
{
if (userseletiongo == null)
return;
//这里有点像图的邻接表
dictionary<nodestate, basenodeview> map = new dictionary<nodestate, basenodeview>();
dictionary<basenodeview, port> inputports = new dictionary<basenodeview, port>();
dictionary<basenodeview, list<port>> outputports = new dictionary<basenodeview, list<port>>();
ports.foreach(x =>
{
var y = x.node;
var node = y as basenodeview;
if (!map.containskey(node.state))
{
map.add(node.state, node);
}
if (!inputports.containskey(node))
{
inputports.add(node, x);
}
if (!outputports.containskey(node))
{
outputports.add(node, new list<port>());
}
if (x.direction == direction.output)
outputports[node].add(x);
});
//只负责连接下面的节点
foreach (var node in map.keys)
{
if (node is basesequence sequence)
{
port x = outputports[map[sequence]][0];
foreach (var nextflow in sequence.nextflows)
{
port y = inputports[map[nextflow]];
addedgebyports(x, y);
}
}
else if (node is basebranch branch)
{
var trueports = outputports[map[branch]][0].portname == "true" ? outputports[map[branch]][0] : outputports[map[branch]][1];
var falseports = outputports[map[branch]][0].portname == "false" ? outputports[map[branch]][0] : outputports[map[branch]][1];
if (branch.trueflow != null)
addedgebyports(trueports, inputports[map[branch.trueflow]]);
if (branch.falseflow != null)
addedgebyports(falseports, inputports[map[branch.falseflow]]);
}
else if (node is monostate state)
{
//普通的action或者trigger,只处理nextflow就好了
if (state.nextflow != null)
addedgebyports(outputports[map[state]][0], inputports[map[state.nextflow]]);
}
}
}
删除与修改节点操作
对于graphview的删除,graphview基类提供了一个委托接口,我们可以关联到它监听图的变化。当对于的点、边、位置被修改时,我们也需要实时更新runtime节点的数据或者删除节点组件.
public flowchartview()
{
//当graphview变化时,调用方法
graphviewchanged += ongraphviewchanged;
}
private graphviewchange ongraphviewchanged(graphviewchange graphviewchange)
{
if (graphviewchange.elementstoremove != null)
{
//对于每个被移除的节点
graphviewchange.elementstoremove.foreach(elem =>
{
basenodeview basenodeview = elem as basenodeview;
if (basenodeview != null)
{
gameobject.destroyimmediate(basenodeview.state);
}
edge edge = elem as edge;
if (edge != null)
{
basenodeview parentview = edge.output.node as basenodeview;
basenodeview childview = edge.input.node as basenodeview;
//if和branch节点特判定
if (edge.output.node is branchnodeview view)
{
if (edge.input.portname == "true")
{
(parentview.state as basebranch).trueflow = null;
}
if (edge.input.portname == "false")
{
(parentview.state as basebranch).falseflow = null;
}
}
else if (edge.output.node is sequencenodeview sqview)
{
(parentview.state as basesequence).nextflows.remove(childview.state as monostate);
}
else
parentview.state.nextflow = null;
}
});
}
//对于每个被创建的边
if (graphviewchange.edgestocreate != null)
{
graphviewchange.edgestocreate.foreach(edge =>
{
basenodeview parentview = edge.output.node as basenodeview;
basenodeview childview = edge.input.node as basenodeview;
//if和branch节点特判定
if (edge.output.node is branchnodeview view)
{
if (edge.output.portname.equals("true"))
{
(parentview.state as basebranch).trueflow = childview.state as monostate;
}
if (edge.output.portname.equals("false"))
{
(parentview.state as basebranch).falseflow = childview.state as monostate;
}
}
else if (edge.output.node is sequencenodeview sqview)
{
(parentview.state as basesequence).nextflows.add(childview.state as monostate);
}
else
parentview.state.nextflow = childview.state as monostate;
});
}
//遍历节点,记录位置点
nodes.foreach((n) =>
{
basenodeview view = n as basenodeview;
if (view != null && view.state != null)
{
view.state.nodepos = view.getposition().position;
}
});
return graphviewchange;
}
创建节点的新建菜单栏
在右键后,新添一个创建节点的菜单。这里使用了linq去遍历和寻找项目中的节点脚本。
在flowchartview的构造函数中添加布局
public flowchartview()
{
//新建搜索菜单
var menuwindowprovider = scriptableobject.createinstance<searchmenuwindowprovider>();
menuwindowprovider.onselectentryhandler = onmenuselectentry;
nodecreationrequest += context =>
{
searchwindow.open(new searchwindowcontext(context.screenmouseposition), menuwindowprovider);
};
}
public class searchmenuwindowprovider : scriptableobject, isearchwindowprovider
{
public list<searchtreeentry> createsearchtree(searchwindowcontext context)
{
var entries = new list<searchtreeentry>();
entries.add(new searchtreegroupentry(new guicontent("创建新节点"))); //添加了一个一级菜单
entries.add(new searchtreegroupentry(new guicontent("触发器")) { level = 1 }); //添加了一个二级菜单
var triggers = getclasslist(typeof(basetrigger));
foreach(var trigger in triggers)
{
entries.add(new searchtreeentry(new guicontent(trigger.name)) { level = 2,userdata = trigger });
}
entries.add(new searchtreegroupentry(new guicontent("行为")) { level = 1 });
var actions = getclasslist(typeof(baseaction));
foreach(var action in actions)
{
entries.add(new searchtreeentry(new guicontent(action.name)) { level = 2, userdata = action });
}
entries.add(new searchtreegroupentry(new guicontent("分支")) { level = 1 });
var branchs = getclasslist(typeof(basebranch));
foreach (var action in branchs)
{
entries.add(new searchtreeentry(new guicontent(action.name)) { level = 2, userdata = action });
}
entries.add(new searchtreegroupentry(new guicontent("序列")) { level = 1 });
var sq = getclasslist(typeof(basesequence));
foreach (var action in sq)
{
entries.add(new searchtreeentry(new guicontent(action.name)) { level = 2, userdata = action });
}
return entries;
}
public delegate bool serchmenuwindowonselectentrydelegate(searchtreeentry searchtreeentry, searchwindowcontext context); //声明一个delegate类
public serchmenuwindowonselectentrydelegate onselectentryhandler; //delegate回调方法
public bool onselectentry(searchtreeentry searchtreeentry, searchwindowcontext context)
{
if (onselectentryhandler == null)
{
return false;
}
return onselectentryhandler(searchtreeentry, context);
}
private list<type> getclasslist(type type)
{
var q = type.assembly.gettypes()
.where(x => !x.isabstract)
.where(x => !x.isgenerictypedefinition)
.where(x => type.isassignablefrom(x));
return q.tolist();
}
}
graphview 复制粘贴操作实现
操作handleevent的事件句柄,检测"paste"操作,可以使用unityeditorinternal.componentutility.pastecomponentvalues复制组件的value
protected boolclass isduplicate = new boolclass();
public override void handleevent(eventbase evt)
{
base.handleevent(evt);
if (evt is validatecommandevent commandevent)
{
debug.log("event:");
debug.log(commandevent.commandname);
//限制一下0.2s执行一次 不然短时间会多次执行
if (commandevent.commandname.equals("paste"))
{
new editordelaycall().checkboolcall(0.2f, isduplicate,
onduplicate);
}
}
}
/// <summary>
/// 复制时
/// </summary>
protected void onduplicate()
{
debug.log("复制节点");
//复制节点
var nodesdict = new dictionary<basenodeview, basenodeview>(); //新旧node对照
foreach (var selectable in selection)
{
var offset = 1;
if (selectable is basenodeview basenodeview)
{
offset++;
unityeditorinternal.componentutility.copycomponent(basenodeview.state);
basenodeview nodeview = null;
var nodeclone = basenodeview.state;
//判断需要复原的节点
if (nodeclone is basetrigger trigger)
nodeview = new triggernodeview();
if (nodeclone is baseaction action)
nodeview = new actionnodeview();
if (nodeclone is basesequence sequence)
nodeview = new sequencenodeview();
if (nodeclone is basebranch branch)
nodeview = new branchnodeview();
if (nodeview == null)
return;
//新旧节点映射
if (nodeview != null)
{
nodesdict.add(basenodeview, nodeview);
}
nodeview.onnodeselected = onnodeselected;
addelement(nodeview);
nodeview.state = (nodestate)userseletiongo.addcomponent(basenodeview.state.gettype());
unityeditorinternal.componentutility.pastecomponentvalues(nodeview.state);
//调整一下流向
//保持原来的流向算法好难写,还是全部设置成null把
nodeview.state.nextflow = null ;
if(nodeview.state is basesequence sq)
{
sq.nextflows = new list<monostate>();
}
if (nodeview.state is basebranch br)
{
br.trueflow = null;
br.falseflow = null;
}
//复制出来的节点位置偏移
nodeview.setposition(new rect(basenodeview.getposition().position + (vector2.one * 30 * offset),nodeview.getposition().size));
}
}
for (int i = selection.count - 1; i >= 0; i--)
{
//取消选择
this.removefromselection(selection[i]);
}
foreach (var node in nodesdict.values)
{
//选择新生成的节点
this.addtoselection(node);
}
}
用到的editor工具脚本:
using system;
using unityeditor;
using unityengine;
public class editordelaycall
{
public class boolclass
{
public bool value;
}
/// <summary>
/// 延迟秒数
/// </summary>
private float _delay;
private action _callback;
private float _startuptime;
public void call(float delay, action callback)
{
this._delay = delay;
this._callback = callback;
editorapplication.update += update;
}
public void checkboolcall(float delay, boolclass boolclass,
action action)
{
if (!boolclass.value)
{
boolclass.value = true;
action?.invoke();
call(delay, delegate { boolclass.value = false; });
}
}
// 主动停止
public void stop()
{
_startuptime = 0;
_callback = null;
editorapplication.update -= update;
}
private void update()
{
// 时间初始化放在这里是因为如果在某些类的构造函数中获取时间是不允许的
if (_startuptime <= 0)
{
_startuptime = time.realtimesincestartup;
}
if (time.realtimesincestartup - _startuptime >= _delay)
{
_callback?.invoke();
stop();
}
}
}
发表评论