Unity的GraphView

Posted 弹吉他的小刘鸭

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity的GraphView相关的知识,希望对你有一定的参考价值。

参考链接:https://www.youtube.com/watch?v=7KHGH0fPL84&ab_channel=MertKirimgeri

GraphView介绍

Unity在2018.1的版本开始加入了一个节点绘制系统,类似于XNode,它不需要在Unity里安装任何Package或者像XNode一样添加任何脚本,只需要使用Unity的官方API即可。Unity里的Shader Graph,VFX Graph和Visual Scripting都是通过Graph View API实现的。这玩意儿适合做Unity的相关编辑器。

相关的API都在对应的命名空间下UnityEditor.Experimental.GraphView

PS: 这一块内容其实是Unity的UI Elements的子集,了解了UI Element,再来学Graph View会更容易上手

下面做一个Demo,在这个Demo里进行Graph View API的学习,这个例子利用GraphView API和Unity的UIElements创建了一个用于人物对话的节点编辑系统,有点类似于蓝图。

1. 创建GraphView和Node的底层类

下面会利用Graph View API创建一个dialogue node system,一个节点系统的图是由图本身和其内部的节点构成的,所以这里创建两个类,分别对应着UI图的类,和UI节点的类,每个类各自对应一个脚本,代码如下:

// 创建dialogue graph的底层类
public class DialogueGraphView: GraphView 
	// 在构造函数里,对GraphView进行一些初始的设置
    public DialogueGraphView() 
    	// 允许对Graph进行Zoom in/out
		SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
        // 允许拖拽Content
        this.AddManipulator(new ContentDragger());
        // 允许Selection里的内容
        this.AddManipulator(new SelectionDragger());
        // GraphView允许进行框选
        this.AddManipulator(new RectangleSelector());
    

// 创建dialogue graph的底层节点类
public class DialogueNode : Node 
    public string GUID;
    public string Text;
    public bool Entry = false;


2. 创建空的Editor Window

在创建完GraphView和Node底层数据之后,要想把它们显示出来,到窗口上,则还需要一个EditorWindow,相关代码如下:

// 代表放置GraphView这个Canvas的EditorWindow
public class DialogueGraphWindow: EditorWindow 
	// 通过Menu即可打开对应window, 注意这种函数必须是static函数
	[MenuItem("Graph/Open Dialogue Graph View")]
	public static void OpenDialogueGraphWindow()
	
		// 定义了创建并打开Window的方法
		var window = GetWindow<DialogueGraphWindow>();
		window.titleContent = new GUIContent( "Dialogue Graph");
	

然后现在点击menu下的Graph->Open Dialogue Graph View,就可以打开一个空窗口了,如下图所示:


3. 将GraphView作为Canvas,展示到对应的EditorWindow里

目前这个Window实际上跟前面的GraphView和Node类没有任何关系,只是一个空窗口,下面可以在Window类里定义GraphView为其数据成员,然后在其OnEnter函数里,对GraphView进行创建和初始化等操作:

public class DialogueGraphWindow : EditorWindow

    private DialogueGraphView _graphView;

	...//原本的打开窗口的函数不变

    private void OnEnable() 
    
        Debug.Log("New GraphView");
        _graphView = new DialogueGraphView
        
            name = "Dialogue Graph"
        ;

        // 让graphView铺满整个Editor窗口
        _graphView.StretchToParentSize();
        // 把它添加到EditorWindow的可视化Root元素下面
        rootVisualElement.Add(_graphView);
    
    
    // 关闭窗口时销毁graphView
    private void OnDisable()
    
        rootVisualElement.Remove(_graphView);
    


// 再在GraphView的构造函数里, 做一些初始化的工作:
public DialogueGraphView() 

    // 允许对Graph进行Zoom in/out
    SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
    // 允许拖拽Content
    this.AddManipulator(new ContentDragger());
    // 允许拖拽Selection里的内容
    this.AddManipulator(new SelectionDragger());
    // GraphView允许进行框选
    this.AddManipulator(new RectangleSelector());

此时再打开Window,就能在里面展示GraphView了,由于在前面的GraphView的ctor里new了RectangleSelector,所以可以在里面进行框选操作,如下图所示:



4.创建初始节点,作为EntryPoint

前面在创建Window时,new出来对应的GraphView。接下来就是创建和展示里面的Nodes了,正常的操作应该是,一个GraphView有一个初始节点,剩下的节点都可以从该节点拖拽出来。所以,初始节点的创建可以放到GraphView的Ctor里进行,代码如下所示:

public class DialogueGraphView: GraphView

    public DialogueGraphView() 
    
		...// Add Manipulator相关的代码

        // 1. 创建StartNode,并设置好其position
        var startNode = GenEntryPointNode();
        // 2. 把node加入到GraphView里
        AddElement(startNode);
        // 3. 给StartNode添加Output Port
        var port = GenPortForNode(startNode, Direction.Output);
        // 4. 给output改名
        port.portName = "Next";
        // 5. 加入到StartNode的outputContainer里
        startNode.outputContainer.Add(port);
    
	
	// 比较简单,相当于new了一个Node
    private DialogueNode GenEntryPointNode() 
    
        DialogueNode node = new DialogueNode
        
            title = "START",
            GUID = Guid.NewGuid().ToString(),// 借助System的Guid生成方法
            Text = "ENTRYPOINT",
            Entry = true
        ;
        node.SetPosition(new Rect(x: 100, y: 200, width: 100, height: 150));

        return node;
     

    // 为节点n创建input port或者output port
    // Direction: 是一个简单的枚举,分为Input和Output两种
    private Port GenPortForNode(Node n, Direction portDir, Port.Capacity capacity = Port.Capacity.Single) 
    
        // Orientation也是个简单的枚举,分为Horizontal和Vertical两种,port的数据类型是float
        return n.InstantiatePort(Orientation.Horizontal, portDir, capacity, typeof(float));
    

此时再打开GraphView,就可以看到对应的StartNode了,如下图所示,Next可以往外拖出Edge,而且Start节点也可以四处拖拽:

仔细看一下,发现上面的Start的左边部分还是不大对劲,这是因为在为node添加port之后,需要调用对应的refresh函数来刷新layout,所以只需要在添加port之后加上refresh的代码即可:

...//创建startNode的相关操作
startNode.outputContainer.Add(port);
// 调用两个refresh函数
startNode.RefreshExpandedState();
startNode.RefreshPorts();

然后布局就会变成正常的样子了:



5. 添加菜单工具栏,点击工具栏可以添加更多的Node

为了实现新功能,需要做两件事情:

  • 设计一个函数,函数可以产生一个Node,函数接收一个string,作为新的DialogueNode的Text
  • 为GraphView添加工具栏,点击工具栏上的Add Node,即调用第一步创建的函数

第一步,写一个可以创建Node的函数,跟前面提到的GenEntryNode的方式类似,无非就是多一个Input的port,代码如下:

// ====================== 在DialogueGraphView类内 ==========================
public void AddDialogueNode(string nodeName)

    // 1. 创建Node
    DialogueNode node = new DialogueNode
    
        title = nodeName,
        GUID = Guid.NewGuid().ToString(),
        Text = nodeName,
        Entry = false
    ;
    node.SetPosition(new Rect(x: 100, y: 200, width: 100, height: 150));

    // 2. 为其创建InputPort
    var iport = GenPortForNode(node, Direction.Input, Port.Capacity.Multi);
    iport.portName = "input";
    node.inputContainer.Add(iport);
    node.RefreshExpandedState();
    node.RefreshPorts();
    
    AddElement(node);

第二步,创建用于添加Node的UI按钮,即Unity的Button对象,这里把button放到统一的一行里了(即toolbar),如下图所示:

相关代码如下:

// =========== 在GraphViewWindow类的OnEnable函数里 ============

//  相关内容涉及到菜单设置,所以应该放到DialogueGraphWindow类下
// 这个Toolbar类在UnityEditor.UIElements下
Toolbar toolbar = new Toolbar();
//创建lambda函数,代表点击按钮后发生的函数调用
Button btn = new Button(clickEvent: () =>  _graphView.AddDialogueNode("Dialogue"); );
btn.text = "Add Dialogue Node";
toolbar.Add(btn);
rootVisualElement.Add(toolbar);

最后的效果就是下图这样了,点击按钮可以在EntryNode相同的地方创建新的Node,可以拖拽出来:


6. 让节点之间可以拖拽连接起来

目前的StartNode节点和创建的节点是不可以连接起来的,根据视频里说的,这是因为还没有对新创建的Node的Input Port作类型要求。

// StartNode的output接口是这么写的:
// 一个水平连线的输出接口,类型好像是float
var startP = n.InstantiatePort(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(float));

// 而新添加的Node的input接口是这么写的:
var newNodeP = n.InstantiatePort(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(float));

要让节点之间可以连线,需要重载函数GetCompatiblePort,代码如下所示:

// =============== 在GraphView类里 =============
// 这个函数是在GraphView里定义的接口, Summary: Get all ports compatible with given port.
// 应该是返回StartPort里可以用于连接的接口
public virtual List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter);

// =============== 在DialogueGraphView类里 =============
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter adapter)

    List<Port> compatiblePorts = new List<Port>();

    // 继承的GraphView里有个Property:ports, 代表graph里所有的port
    ports.ForEach((port) =>
    
        // 对每一个在graph里的port,进行判断,这里有两个规则:
        // 1. port不可以与自身相连
        // 2. 同一个节点的port之间不可以相连
        if (port != startPort && port.node != startPort.node)
        
            compatiblePorts.Add(port);
        
    );

    // 在我理解,这个函数就是把所有除了startNode里的port都收集起来,放到了List里
    // 所以这个函数能让StartNode的Output port与任何其他的Node的Input port相连(output port应该默认不能与output port相连吧)
    return compatiblePorts;
	

OK,现在就可以把StartNode跟其他的Node相连了,不过一个Output口目前好像只能连一个Node,如下图所示:


7. 为节点添加output port

一个节点其output port的数量应该是可以通过节点的GUI来调整的,预期是做出下图这样的情况,当点击Add Output时,会添加Output端口:

相关的行为也可以分为两步:

  • 设计一个函数,这个函数的参数是Node,它会为Node添加一个Output port
  • 相关的GUI设计,当点击Node的title上的button时,调用第一步设计的函数

第一步,设计函数,代码如下:

// ============== DialogueGraphView类里 ==================
private void AddOutputPort(DialogueNode node)

    var outPort = GenPortForNode(node, Direction.Output);

    // 根据node的outport的数目给新的outport命名
    var count = node.outputContainer.Query("connector").ToList().Count;
    string name = $"Output count";
    outPort.portName = name;
    node.outputContainer.Add(outPort);
    node.RefreshExpandedState();
    node.RefreshPorts();

第二步,实现对应的GUI部分,相关的代码可以直接放到Node的创建函数里,代码如下:

// ================= DialogueGraphView类 ==============
private DialogueNode GenDialogueNode(string nodeName)

    // 1. 创建Node
	...

    // 2. 为其创建InputPort
	...

    // 3. 为其在title上创建btn, 点击btn时会调用函数
    Button btn = new Button(() => 
    
        AddOutputPort(node);
    );
    btn.text = "Add Output Port";
    node.titleContainer.Add(btn);

    return node;

这样就能实现前面图里面贴出来的效果了


8. 为Graph添加背景的网格框架

为了更完善Graph窗口,这里介绍一种为其产生背景网格线的方法,如下图所示:

首先,在Editor文件夹下创建Resources文件夹,然后在Project View里选择右键,Create->UIElements->Editor Window,取消勾选C#和UXML,如下图所示:

文件里面写:

GridBackground 
    --grid-background-color: #282828;
    --line-color: rgba(193, 196, 192, 0.1);
    --thick-line-color: rgba(193, 196, 192, 0.1);

最后在创建对应的GrahView的Init阶段,读取该uss文件作为StyleSheet即可:

public DialogueGraphView() 

    // 不知道为啥没有起作用, 这里是s是读取到了东西的, 原因Remain
    StyleSheet s = Resources.Load<StyleSheet>("DialogueGraph");
    styleSheets.Add(s);
	...


9. 创建更多的菜单工具栏选项

对话节点编辑器的设计思路是这样的,在Unity的Window里进行创建和编辑,然后可以把它存储起来,在Runtime下给游戏去读取。当在Editor下点击该文件时,对应的GraphView也会蹦出来。

为了补充Save和Load功能,可以先把UI做好,这里设计了两个Button,用于点击Save和Load,又设计了一个TextField,用于指定存储的文件的名字。

之前只在菜单栏对应的toolbar里添加了一个button,代码如下:

// 在EditorWindow的继承类里这么写
Toolbar toolbar = new Toolbar();
Button btn = new Button(clickEvent: () =>  _graphView.AddDialogueNode("Dialogue"); );
btn.text = "Add Dialogue Node";
toolbar.Add(btn);
rootVisualElement.Add(toolbar);

现在添加更多的选项,一种仍然是Button,另一种则是TextField,代码如下所示:

// 添加TextField
TextField fileNameTextField = new TextField(label: "File Name");
fileNameTextField.SetValueWithoutNotify(_fileName);// 类内私有成员_fileName = "New Narrative";
fileNameTextField.MarkDirtyRepaint();
fileNameTextField.RegisterValueChangedCallback(evt => _fileName = evt.newValue);
toolbar.Add(fileNameTextField);

// 添加两Button
// 不熟悉这种写法,text是Button的数据成员
// LodaData和SaveData两个函数暂时还没实现
toolbar.Add(new Button(() => SaveData())  text = "Save Data" );
toolbar.Add(new Button(() => LoadData())  text = "Load Data" );

之后的toolbar就会变成这样,多了三个元素,两个Button用于存储和读取数据,TextFiled用于指定文件路径:

然后就可以创建具体的底层代码了,从设计角度上,SaveData和LoadData没有必要放在DialogueGraphView类里,这里创建了一个GraphSaveUtility类,代码如下:

public class GraphSaveUtility

    private DialogueGraphView _dialogueGraphView;

    // 每次从类里获取edges和nodes时,都会去取graphView里的内容,并进行转型
    private List<Edge> edges => _dialogueGraphView.edges.ToList();
    private List<DialogueNode> nodes => _dialogueGraphView.nodes.ToList().Cast<DialogueNode>().ToList();

    public static GraphSaveUtility GetInstance(DialogueGraphView graphView)
    
        return new GraphSaveUtility
        
            _dialogueGraphView = graphView
        ;
    

    public void SaveData()
    
    

    public void LoadData()
    
    

为了实现SaveData和LoadData函数,先要实现相关的Runtime下的存储文件类,这里使用ScriptableObject作为存储DialogugGraph的存储数据文件:

// 感觉这个类跟之前创建的DialogueNode类非常类似
[Serializable]
public class DialogueNodeData

	public string nodeGUID;// 对应node的GUID
	public string nodeText;// 对应node的text
	public Vector2 position;


// 用于存储Node之间的连接关系, 在GraphView里是通过Port连接起来的,
// 存储的时候要额外创建一个NodeLink
[Serializable]
public class DialogueNodeLinkData

	public string baseNodeGuid;
	public string portName;
	public string targetNodeGuid;


// 这里贴一下之前创建的DialogueNode类, 方便前后比对
public class DialogueNode : Node 
    public string guid;
    public string text;
    public bool entry = false;

在创建好了Node和NodeLink对应的可序列化的数据结构后,就可以创建整个Graph对应的可序列化的数据结构了,代码如下所示:

[Serializable]
public class DialogueContainer : ScriptableObject

	// 创建两个List, 分别代表nodes和nodeLinks
	public List<DialogueNodeData> nodesData = new List<NodeData>();
	public List<DialogueNodeLinkData> nodeLinksData = new List<NodeLinkData>();

有了这些,就可以实现SaveData和LoadData函数了:

// Save函数的核心代码
public void SaveData(string filePath)

    if (!edges.Any())
        return;

    DialogueContainer container = ScriptableObject.CreateInstance<DialogueContainer>();

    // 遍历所有的Edge, 找到里面有Input的Edge, 组成一个数组
    Edge[] hasInputEdges = edges.Where(x => x.input.node != null).ToArray();
    // 注意, edge的Input在右边, Output在左边
    for (int i = 0; i < hasInputEdges.Length; i++)
    
        Edge e = hasInputEdges[i]
        
                

1.介绍

1.无限的分支和合并的对话能力。
2.对话框、图形保存/加载系统。
3.小地图,便于导航。
4.有节点创建的搜索窗口。
5.黑板填写属性值。
6.用于分组节点的注释块。
7.由Unity的嵌入式GraphView API支持。
8.在压缩包中提供完整demo和代码注释。 

 

2.基础知识

 1.GraphView

Experimental.GraphView.GraphView - Unity 脚本 APIhttps://docs.unity.cn/cn/current/ScriptReference/Experimental.GraphView.GraphView.html2.UIElement

Unity2019 UIElement 笔记(一)创建脚本_工 具 人-CSDN博客https://blog.csdn.net/qq_43500611/article/details/89604455

3.Node

Experimental.GraphView.Node - Unity 脚本 APIhttps://docs.unity.cn/cn/current/ScriptReference/Experimental.GraphView.Node.html

3.部分代码示例

       public StoryGraphView(StoryGraph editorWindow)
       
            //网格绘制
            styleSheets.Add(Resources.Load<StyleSheet>("NarrativeGraph"));
            SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);

            this.AddManipulator(new ContentDragger());
            this.AddManipulator(new SelectionDragger());
            this.AddManipulator(new RectangleSelector());
            this.AddManipulator(new FreehandSelector());

            var grid = new GridBackground();
            Insert(0, grid);
            grid.StretchToParentSize();

            //开始节点
            AddElement(GetEntryPointNodeInstance());
            //搜索窗口
            AddSearchWindow(editorWindow);
        
 /// <summary>
        /// 工具栏绘制
        /// </summary>
        private void GenerateToolbar()
        
            var toolbar = new Toolbar();

            var fileNameTextField = new TextField("File Name:");
            fileNameTextField.SetValueWithoutNotify(_fileName);
            fileNameTextField.MarkDirtyRepaint();
            fileNameTextField.RegisterValueChangedCallback(evt => _fileName = evt.newValue);
            toolbar.Add(fileNameTextField);

            toolbar.Add(new Button(() => RequestDataOperation(true)) text = "Save Data");

            toolbar.Add(new Button(() => RequestDataOperation(false)) text = "Load Data");
            // toolbar.Add(new Button(() => _graphView.CreateNewDialogueNode("Dialogue Node")) text = "New Node",);
            rootVisualElement.Add(toolbar);
        


/// <summary>
        /// 小地图绘制
        /// </summary>
        private void GenerateMiniMap()
        
            var miniMap = new MiniMap anchored = true;
            var cords = _graphView.contentViewContainer.WorldToLocal(new Vector2(this.maxSize.x - 10, 30));
            miniMap.SetPosition(new Rect(cords.x, cords.y, 200, 140));
            _graphView.Add(miniMap);
        


/// <summary>
        /// 小黑板绘制
        /// </summary>
        private void GenerateBlackBoard()
        
            var blackboard = new Blackboard(_graphView);
            blackboard.Add(new BlackboardSection title = "Exposed Variables");
            blackboard.addItemRequested = _blackboard =>
            
                _graphView.AddPropertyToBlackBoard(ExposedProperty.CreateInstance(), false);
            ;
            blackboard.editTextRequested = (_blackboard, element, newValue) =>
            
                var oldPropertyName = ((BlackboardField) element).text;
                if (_graphView.ExposedProperties.Any(x => x.PropertyName == newValue))
                
                    EditorUtility.DisplayDialog("Error", "This property name already exists, please chose another one.",
                        "OK");
                    return;
                

                var targetIndex = _graphView.ExposedProperties.FindIndex(x => x.PropertyName == oldPropertyName);
                _graphView.ExposedProperties[targetIndex].PropertyName = newValue;
                ((BlackboardField) element).text = newValue;
            ;
            blackboard.SetPosition(new Rect(10,30,200,300));
            _graphView.Add(blackboard);
            _graphView.Blackboard = blackboard;
        

代码资源下载地址

Unity对话系统编辑器-互联网文档类资源-CSDN文库https://download.csdn.net/download/m0_46712616/46797276

以上是关于Unity的GraphView的主要内容,如果未能解决你的问题,请参考以下文章

unity和unity3D的区别

Unity3D资源文件 ③ ( Unity 资源包简介 | 导出 Unity 资源包 | 导出资源包的包含依赖选项 | 导入 Unity 资源包 | Unity 资源商店 )

unity导出的apk装到手机上只有摄像头

Unity前景如何?现在unity还能找到工作吗?

unity游戏unity 攻击范围绘制圆圈怎么做

换了最新版本的unity打开以前的工程除了好多警告